From ba72fce8a9333fb6c57162c9ce788dd47fb38f81 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:14:36 -0400 Subject: [PATCH] feat(ui): mark entities queued for deletion across every list (#50) System Safety queues destructive actions behind a grace period rather than doing them immediately, so an entity can sit in a list looking perfectly alive while already scheduled for deletion. Home's banner said the system had pending actions, but nothing said which rows they were. Adds a shared badge, "Deletes in 18h", plus a dimmed row, wherever such a thing is listed: members, groups, front history, journals, board messages, polls, reminders, tags, custom fields and notification channels, and again on the member, group, poll and journal detail screens so the marking survives the tap that opens them. Only NotificationChannelRead carried pending_delete_at; the other ten models had no idea the field existed, so this adds it to each. JournalEntryRead has a parallel JournalEntryReadWithCount that the detail screen reads, which needed it too, or an entry would look safe the moment you opened it. The badge is deliberately not clickable. These sit inside cards that are themselves clickable, and a tap target inside a tap target is a coin toss; cancelling stays in Settings > Safety, which the Home banner deep-links to. The countdown now rounds down rather than up, which also fixes the Home banner it was lifted from: this is a deadline for undoing something destructive, and telling someone they have 2 days when 25 hours remain can cost them the window, while erring short only makes them act sooner. Below an hour it says "in under an hour" rather than flooring to a "in 0h" that reads as already gone. Home now shares that one implementation instead of keeping its own copy. Not covered: watch tokens carry the field on the server but have no list UI here to mark. --- .../lupine/sheaf/data/model/MessageModels.kt | 3 + .../systems/lupine/sheaf/data/model/Models.kt | 21 ++++ .../data/model/NotificationChannelModels.kt | 3 + .../lupine/sheaf/data/model/PollModels.kt | 3 + .../lupine/sheaf/data/model/ReminderModels.kt | 3 + .../sheaf/ui/components/PendingDelete.kt | 111 ++++++++++++++++++ .../sheaf/ui/fields/CustomFieldsScreen.kt | 11 +- .../lupine/sheaf/ui/groups/GroupsScreen.kt | 12 +- .../lupine/sheaf/ui/history/HistoryScreen.kt | 9 +- .../lupine/sheaf/ui/home/HomeScreen.kt | 25 +--- .../sheaf/ui/journals/JournalsScreen.kt | 7 +- .../lupine/sheaf/ui/members/MembersScreen.kt | 5 + .../sheaf/ui/messages/BoardDetailScreen.kt | 10 +- .../ui/notifications/ChannelsYouOwnScreen.kt | 8 +- .../reminders/RemindersScreen.kt | 8 +- .../lupine/sheaf/ui/people/PeopleScreen.kt | 12 +- .../lupine/sheaf/ui/polls/PollDetailScreen.kt | 2 + .../lupine/sheaf/ui/polls/PollsScreen.kt | 8 +- .../lupine/sheaf/ui/tags/TagsManagerScreen.kt | 5 + .../sheaf/ui/components/PendingDeleteTest.kt | 67 +++++++++++ 20 files changed, 304 insertions(+), 29 deletions(-) create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/PendingDelete.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/ui/components/PendingDeleteTest.kt diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/MessageModels.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/MessageModels.kt index 32d1764..17b4aa6 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/MessageModels.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/MessageModels.kt @@ -31,6 +31,9 @@ data class MessageRead( @Json(name = "body") val body: String, @Json(name = "created_at") val createdAt: String, @Json(name = "updated_at") val updatedAt: String, + // Set when a System Safety grace period has this queued for deletion. + // Still returned and still usable until the window closes; the UI marks it. + @Json(name = "pending_delete_at") val pendingDeleteAt: String? = null, ) @JsonClass(generateAdapter = true) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index 0b1e738..8bd8b4e 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt @@ -430,6 +430,9 @@ data class MemberRead( // endpoint still returns archived members, so the client filters them // out of the main roster and surfaces them separately. @Json(name = "archived_at") val archivedAt: String? = null, + // Set when a System Safety grace period has this queued for deletion. + // Still returned and still usable until the window closes; the UI marks it. + @Json(name = "pending_delete_at") val pendingDeleteAt: String? = null, ) { val displayNameOrName: String get() = displayName?.takeIf { it.isNotBlank() } ?: name val isArchived: Boolean get() = archivedAt != null @@ -500,6 +503,9 @@ data class FrontRead( // Member ids whose member_since hit the server-side walk-back depth cap. // Their timestamp is a lower bound; UI should prefix with "> ". @Json(name = "member_since_capped") val memberSinceCapped: List = emptyList(), + // Set when a System Safety grace period has this queued for deletion. + // Still returned and still usable until the window closes; the UI marks it. + @Json(name = "pending_delete_at") val pendingDeleteAt: String? = null, ) @JsonClass(generateAdapter = true) @@ -541,6 +547,9 @@ data class GroupRead( @Json(name = "parent_id") val parentId: String?, @Json(name = "created_at") val createdAt: String, @Json(name = "updated_at") val updatedAt: String, + // Set when a System Safety grace period has this queued for deletion. + // Still returned and still usable until the window closes; the UI marks it. + @Json(name = "pending_delete_at") val pendingDeleteAt: String? = null, ) @JsonClass(generateAdapter = true) @@ -574,6 +583,9 @@ data class TagRead( val color: String?, @Json(name = "created_at") val createdAt: String, @Json(name = "updated_at") val updatedAt: String, + // Set when a System Safety grace period has this queued for deletion. + // Still returned and still usable until the window closes; the UI marks it. + @Json(name = "pending_delete_at") val pendingDeleteAt: String? = null, ) @JsonClass(generateAdapter = true) @@ -621,6 +633,9 @@ data class CustomFieldRead( val privacy: String, @Json(name = "created_at") val createdAt: String, @Json(name = "updated_at") val updatedAt: String, + // Set when a System Safety grace period has this queued for deletion. + // Still returned and still usable until the window closes; the UI marks it. + @Json(name = "pending_delete_at") val pendingDeleteAt: String? = null, ) { val fieldTypeDisplay: String get() = fieldType.replaceFirstChar { it.uppercase() } val privacyDisplay: String get() = privacy.replaceFirstChar { it.uppercase() } @@ -1465,6 +1480,9 @@ data class JournalEntryRead( @Json(name = "author_member_names") val authorMemberNames: List = emptyList(), @Json(name = "created_at") val createdAt: String, @Json(name = "updated_at") val updatedAt: String, + // Set when a System Safety grace period has this queued for deletion. + // Still returned and still usable until the window closes; the UI marks it. + @Json(name = "pending_delete_at") val pendingDeleteAt: String? = null, ) @JsonClass(generateAdapter = true) @@ -1481,6 +1499,9 @@ data class JournalEntryReadWithCount( @Json(name = "created_at") val createdAt: String, @Json(name = "updated_at") val updatedAt: String, @Json(name = "revision_count") val revisionCount: Int = 0, + // Mirrors JournalEntryRead: the detail screen reads this variant, so the + // field has to exist on both or the entry looks safe once you open it. + @Json(name = "pending_delete_at") val pendingDeleteAt: String? = null, ) @JsonClass(generateAdapter = true) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/NotificationChannelModels.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/NotificationChannelModels.kt index 5e3bde7..b4e617e 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/NotificationChannelModels.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/NotificationChannelModels.kt @@ -19,6 +19,9 @@ data class WatchTokenRead( @Json(name = "created_at") val createdAt: String, @Json(name = "updated_at") val updatedAt: String, @Json(name = "channel_count") val channelCount: Int = 0, + // Set when a System Safety grace period has this queued for deletion. + // Still returned and still usable until the window closes; the UI marks it. + @Json(name = "pending_delete_at") val pendingDeleteAt: String? = null, ) // ── Layer-2 / Layer-3 rule specs ───────────────────────────────────────────── diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/PollModels.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/PollModels.kt index cae85d4..c9f5e9d 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/PollModels.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/PollModels.kt @@ -70,6 +70,9 @@ data class PollRead( @Json(name = "votes") val votes: List? = null, @Json(name = "created_at") val createdAt: String, @Json(name = "updated_at") val updatedAt: String, + // Set when a System Safety grace period has this queued for deletion. + // Still returned and still usable until the window closes; the UI marks it. + @Json(name = "pending_delete_at") val pendingDeleteAt: String? = null, ) @JsonClass(generateAdapter = true) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/ReminderModels.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/ReminderModels.kt index a21ad52..84e7e3f 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/ReminderModels.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/ReminderModels.kt @@ -68,4 +68,7 @@ data class ReminderRead( @Json(name = "last_fired_at") val lastFiredAt: String? = null, @Json(name = "pending_count") val pendingCount: Int = 0, @Json(name = "next_fire_at") val nextFireAt: String? = null, + // Set when a System Safety grace period has this queued for deletion. + // Still returned and still usable until the window closes; the UI marks it. + @Json(name = "pending_delete_at") val pendingDeleteAt: String? = null, ) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/PendingDelete.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/PendingDelete.kt new file mode 100644 index 0000000..989312b --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/PendingDelete.kt @@ -0,0 +1,111 @@ +package systems.lupine.sheaf.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.unit.dp +import systems.lupine.sheaf.ui.theme.LocalWarningColors +import java.time.Duration +import java.time.OffsetDateTime +import java.time.format.DateTimeParseException + +/** + * System Safety queues destructive actions behind a grace period instead of + * doing them immediately, so an entity can be alive on screen and already + * scheduled for deletion. These helpers are the shared vocabulary for saying so + * consistently wherever such a thing is listed. + */ + +/** Parse a `pending_delete_at` / `finalize_after` timestamp, null-safe. */ +fun parseFinalizeAt(iso: String?): OffsetDateTime? = + if (iso.isNullOrBlank()) null + else try { + OffsetDateTime.parse(iso) + } catch (_: DateTimeParseException) { + null + } + +/** + * How long until [target], phrased for a badge: "in 18h", "in 3 days". + * + * Hours below a day, days above it: a grace period is configured in days, and + * "in 47 hours" is harder to act on than "in 2 days". + * + * Rounds **down** throughout. This is a deadline for undoing something + * destructive, so the two rounding errors are not equal: telling someone they + * have 2 days when 25 hours remain can cost them the window, while telling them + * 1 day when 25 hours remain only makes them act sooner. Below an hour there is + * no floor left to give, so it says so in words rather than showing "in 0h". + * + * Already-elapsed windows read as "any moment" rather than a negative, since the + * sweep that finalises them runs on its own schedule and a row can briefly + * outlive its own deadline. + */ +fun formatFinalizeCountdown(target: OffsetDateTime): String { + val duration = Duration.between(OffsetDateTime.now(), target) + if (duration.isNegative || duration.isZero) return "any moment" + val hours = duration.toHours() + if (hours < 1) return "in under an hour" + if (hours < 24) return "in ${hours}h" + val days = duration.toDays() + return if (days == 1L) "in 1 day" else "in $days days" +} + +/** Opacity for a row whose entity is pending deletion, mirroring web. */ +const val PENDING_DELETE_ALPHA = 0.6f + +/** + * Badge for an entity awaiting a queued delete: "Deletes in 18h". + * + * Renders nothing when [pendingDeleteAt] is null, so it can be dropped + * unconditionally into any row. Deliberately not clickable: these sit inside + * cards that are themselves clickable, and a tap target inside a tap target is + * a coin toss. Cancelling lives where it already did, in Settings > Safety, + * which the Home banner deep-links to. + */ +@Composable +fun PendingDeleteBadge( + pendingDeleteAt: String?, + modifier: Modifier = Modifier, +) { + val target = parseFinalizeAt(pendingDeleteAt) ?: return + val warning = LocalWarningColors.current + val countdown = formatFinalizeCountdown(target) + Surface( + color = warning.container, + contentColor = warning.onContainer, + shape = RoundedCornerShape(50), + // The icon is decorative and the text already says everything; merge + // the whole badge into one announcement instead of two fragments. + modifier = modifier.clearAndSetSemantics { + contentDescription = "Pending delete, finalises $countdown" + }, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Schedule, + contentDescription = null, + modifier = Modifier.size(13.dp), + ) + Text( + text = " Deletes $countdown", + style = MaterialTheme.typography.labelSmall, + ) + } + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt index 3b10edb..46be357 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import systems.lupine.sheaf.data.model.CustomFieldRead import systems.lupine.sheaf.ui.components.* +import androidx.compose.ui.draw.alpha // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -183,7 +184,10 @@ private fun FieldListItem( Text(field.name, style = MaterialTheme.typography.titleMedium) }, supportingContent = { - Text(field.fieldTypeDisplay, style = MaterialTheme.typography.bodySmall) + Column { + Text(field.fieldTypeDisplay, style = MaterialTheme.typography.bodySmall) + PendingDeleteBadge(field.pendingDeleteAt) + } }, leadingContent = { Icon( @@ -215,7 +219,10 @@ private fun FieldListItem( } } }, - modifier = Modifier.fillMaxWidth().clickable { onClick() }, + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .alpha(if (field.pendingDeleteAt != null) PENDING_DELETE_ALPHA else 1f), ) HorizontalDivider() } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt index 8be57ac..12a96bc 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.viewModelScope import systems.lupine.sheaf.ui.components.* import systems.lupine.sheaf.ui.relationships.REL_SCOPE_GROUP import systems.lupine.sheaf.ui.relationships.RelationshipsEditor +import androidx.compose.ui.draw.alpha // ── Group list card ─────────────────────────────────────────────────────────── @@ -46,11 +47,15 @@ internal fun GroupCard( onEdit: () -> Unit, ) { val accent = parseColor(group.color ?: "#534AB7") ?: MaterialTheme.colorScheme.primary + val pending = group.pendingDeleteAt != null Card( onClick = onToggleExpand, // Indent subgroups under their parent. Capped so deep nesting stays // usable on a narrow screen. - modifier = Modifier.fillMaxWidth().padding(start = (minOf(depth, 4) * 16).dp), + modifier = Modifier + .fillMaxWidth() + .padding(start = (minOf(depth, 4) * 16).dp) + .alpha(if (pending) PENDING_DELETE_ALPHA else 1f), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { Row( @@ -83,6 +88,10 @@ internal fun GroupCard( overflow = TextOverflow.Ellipsis, ) } + PendingDeleteBadge( + group.pendingDeleteAt, + modifier = Modifier.padding(top = 4.dp), + ) } IconButton(onClick = onEdit) { Icon( @@ -223,6 +232,7 @@ fun GroupDetailScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { if (state.error != null) ErrorBanner(state.error!!) + PendingDeleteBadge(state.group?.pendingDeleteAt) OutlinedTextField( value = form.name, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt index 3e91356..1890b2e 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt @@ -57,6 +57,9 @@ import java.time.format.DateTimeFormatter import java.time.format.TextStyle as JTimeTextStyle import java.time.temporal.ChronoUnit import java.util.Locale +import androidx.compose.ui.draw.alpha +import systems.lupine.sheaf.ui.components.PENDING_DELETE_ALPHA +import systems.lupine.sheaf.ui.components.PendingDeleteBadge @OptIn(ExperimentalFoundationApi::class) @Composable @@ -602,10 +605,14 @@ private fun FrontHistoryCard( ) { val isActive = front.endedAt == null Card( - modifier = Modifier.fillMaxWidth().combinedClickable(onClick = onClick, onLongClick = onLongClick), + modifier = Modifier + .fillMaxWidth() + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .alpha(if (front.pendingDeleteAt != null) PENDING_DELETE_ALPHA else 1f), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { Column(modifier = Modifier.padding(16.dp)) { + PendingDeleteBadge(front.pendingDeleteAt) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt index 88b0a74..7106c9e 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt @@ -669,7 +669,7 @@ private fun SafetyPendingBanner( } private fun safetyBannerMessage(kind: SafetyBannerKind, count: Int, earliest: OffsetDateTime?): String { - val time = earliest?.let { formatRelativeFinalize(it) } ?: "soon" + val time = earliest?.let { formatFinalizeCountdown(it) } ?: "soon" return when (kind) { SafetyBannerKind.ACTIONS -> if (count == 1) "1 pending destructive action — finalizes $time." @@ -680,23 +680,10 @@ private fun safetyBannerMessage(kind: SafetyBannerKind, count: Int, earliest: Of } } -private fun formatRelativeFinalize(target: OffsetDateTime): String { - val duration = Duration.between(OffsetDateTime.now(), target) - if (duration.isNegative || duration.isZero) return "any moment" - val hours = duration.toHours() - if (hours < 24) { - val h = (duration.toMinutes() + 59) / 60 - return "in ${h}h" - } - val days = (duration.toMinutes() + 24 * 60 - 1) / (24 * 60) - return if (days == 1L) "in 1 day" else "in $days days" -} - -private fun parseFinalize(iso: String): OffsetDateTime? = try { - OffsetDateTime.parse(iso) -} catch (_: DateTimeParseException) { - null -} +// Countdown + parsing live in ui/components/PendingDelete.kt: the banner here +// and the per-row badges describe the same deadline, so they share one +// implementation rather than drifting apart. +private fun parseFinalize(iso: String): OffsetDateTime? = parseFinalizeAt(iso) // ── Retention trim-notice banner ────────────────────────────────────────────── @@ -730,7 +717,7 @@ private fun TrimNoticePendingBanner( tint = onContainerColor, modifier = Modifier.size(20.dp), ) - val time = effectiveAt?.let { formatRelativeFinalize(it) } ?: "soon" + val time = effectiveAt?.let { formatFinalizeCountdown(it) } ?: "soon" Text( "Plan downgrade trim pending: revisions over the new tier limits will be pruned $time. Tap to review.", style = MaterialTheme.typography.bodyMedium, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/journals/JournalsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/journals/JournalsScreen.kt index 2bfd93b..e13e3f8 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/journals/JournalsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/journals/JournalsScreen.kt @@ -46,6 +46,7 @@ import systems.lupine.sheaf.ui.components.* import java.time.OffsetDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter +import androidx.compose.ui.draw.alpha // ── Journals list ───────────────────────────────────────────────────────────── @@ -180,10 +181,13 @@ private fun JournalCard( ) { Card( onClick = onClick, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .alpha(if (entry.pendingDeleteAt != null) PENDING_DELETE_ALPHA else 1f), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + PendingDeleteBadge(entry.pendingDeleteAt) Row(verticalAlignment = Alignment.CenterVertically) { Text( entry.title?.takeIf { it.isNotBlank() } ?: "Untitled", @@ -327,6 +331,7 @@ fun JournalDetailScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { if (state.error != null) ErrorBanner(state.error!!) + PendingDeleteBadge(state.entry?.pendingDeleteAt) if (state.isEditing) { JournalEditor( diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt index 73dfbcb..b7460fc 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt @@ -966,6 +966,11 @@ fun MemberProfileScreen( verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { + // Above everything, including the banner: if this member is + // queued for deletion that is the most important thing on + // the screen, and the list row that led here said so too. + PendingDeleteBadge(member.pendingDeleteAt) + // Banner header (3:1), shown only when set. Profile-only; // member lists deliberately omit it to stay scannable. if (!member.bannerUrl.isNullOrEmpty()) { diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/messages/BoardDetailScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/messages/BoardDetailScreen.kt index c8006e1..63c1764 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/messages/BoardDetailScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/messages/BoardDetailScreen.kt @@ -55,6 +55,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import systems.lupine.sheaf.data.model.MessageRead import systems.lupine.sheaf.ui.components.ErrorBanner import systems.lupine.sheaf.ui.components.SheafTopAppBar +import androidx.compose.ui.draw.alpha +import systems.lupine.sheaf.ui.components.PENDING_DELETE_ALPHA +import systems.lupine.sheaf.ui.components.PendingDeleteBadge @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -287,7 +290,12 @@ private fun MessageBubble( onJumpToParent: (parentId: String) -> Unit, parentIsOnPage: Boolean, ) { - Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .alpha(if (message.pendingDeleteAt != null) PENDING_DELETE_ALPHA else 1f), + ) { + PendingDeleteBadge(message.pendingDeleteAt) Row(verticalAlignment = Alignment.CenterVertically) { Text( message.authorMemberName ?: "[deleted member]", diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/ChannelsYouOwnScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/ChannelsYouOwnScreen.kt index 798fcee..9be0e3c 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/ChannelsYouOwnScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/ChannelsYouOwnScreen.kt @@ -51,6 +51,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import systems.lupine.sheaf.data.model.NotificationChannelRead import systems.lupine.sheaf.ui.components.ErrorBanner import systems.lupine.sheaf.ui.components.SheafTopAppBar +import androidx.compose.ui.draw.alpha +import systems.lupine.sheaf.ui.components.PENDING_DELETE_ALPHA +import systems.lupine.sheaf.ui.components.PendingDeleteBadge @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -161,7 +164,9 @@ private fun ChannelRow( val isPending = channel.destinationState.equals("pending_registration", ignoreCase = true) val isDisabled = channel.destinationState.equals("disabled", ignoreCase = true) ListItem( - modifier = Modifier.clickable(onClick = onClick), + modifier = Modifier + .clickable(onClick = onClick) + .alpha(if (channel.pendingDeleteAt != null) PENDING_DELETE_ALPHA else 1f), headlineContent = { Text(channel.name) }, supportingContent = { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { @@ -186,6 +191,7 @@ private fun ChannelRow( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + PendingDeleteBadge(channel.pendingDeleteAt) } }, leadingContent = { diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/reminders/RemindersScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/reminders/RemindersScreen.kt index 1396847..f6e1538 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/reminders/RemindersScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/reminders/RemindersScreen.kt @@ -51,6 +51,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import systems.lupine.sheaf.data.model.ReminderRead import systems.lupine.sheaf.ui.components.ErrorBanner import systems.lupine.sheaf.ui.components.SheafTopAppBar +import androidx.compose.ui.draw.alpha +import systems.lupine.sheaf.ui.components.PENDING_DELETE_ALPHA +import systems.lupine.sheaf.ui.components.PendingDeleteBadge @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -159,7 +162,9 @@ private fun ReminderRow( onDelete: () -> Unit, ) { ListItem( - modifier = Modifier.clickable(onClick = onClick), + modifier = Modifier + .clickable(onClick = onClick) + .alpha(if (reminder.pendingDeleteAt != null) PENDING_DELETE_ALPHA else 1f), headlineContent = { Text(reminder.name) }, supportingContent = { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { @@ -182,6 +187,7 @@ private fun ReminderRow( color = MaterialTheme.colorScheme.error, ) } + PendingDeleteBadge(reminder.pendingDeleteAt) } }, leadingContent = { diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/people/PeopleScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/people/PeopleScreen.kt index 8651fd8..741437b 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/people/PeopleScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/people/PeopleScreen.kt @@ -34,6 +34,7 @@ import systems.lupine.sheaf.ui.groups.GroupCard import systems.lupine.sheaf.ui.groups.GroupsViewModel import systems.lupine.sheaf.ui.groups.orderGroupsHierarchically import systems.lupine.sheaf.ui.members.MembersViewModel +import androidx.compose.ui.draw.alpha private enum class PeopleTab { MEMBERS, GROUPS } @@ -340,9 +341,14 @@ private fun GroupsTabBody( @Composable private fun MemberCard(member: MemberRead, onClick: () -> Unit) { + val pending = member.pendingDeleteAt != null Card( onClick = onClick, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + // Dim the whole row, badge included: a queued delete should read as + // "on its way out" at a glance, before any label is read. + .alpha(if (pending) PENDING_DELETE_ALPHA else 1f), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { @@ -364,6 +370,10 @@ private fun MemberCard(member: MemberRead, onClick: () -> Unit) { overflow = TextOverflow.Ellipsis, ) } + PendingDeleteBadge( + member.pendingDeleteAt, + modifier = Modifier.padding(top = 4.dp), + ) } } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/polls/PollDetailScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/polls/PollDetailScreen.kt index 73ecaa6..6282833 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/polls/PollDetailScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/polls/PollDetailScreen.kt @@ -58,6 +58,7 @@ import systems.lupine.sheaf.data.model.PollRead import systems.lupine.sheaf.ui.components.ErrorBanner import systems.lupine.sheaf.ui.components.SectionHeader import systems.lupine.sheaf.ui.components.SheafTopAppBar +import systems.lupine.sheaf.ui.components.PendingDeleteBadge @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -109,6 +110,7 @@ fun PollDetailScreen( private fun Content(state: PollDetailUiState, vm: PollDetailViewModel) { val poll = state.poll!! Spacer(Modifier.height(8.dp)) + PendingDeleteBadge(poll.pendingDeleteAt) Text(poll.question, style = MaterialTheme.typography.headlineSmall) if (!poll.description.isNullOrBlank()) { Spacer(Modifier.height(4.dp)) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/polls/PollsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/polls/PollsScreen.kt index e41f2c5..ef2a747 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/polls/PollsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/polls/PollsScreen.kt @@ -40,6 +40,9 @@ import systems.lupine.sheaf.data.model.PollRead import systems.lupine.sheaf.ui.components.ErrorBanner import systems.lupine.sheaf.ui.components.SectionHeader import systems.lupine.sheaf.ui.components.SheafTopAppBar +import androidx.compose.ui.draw.alpha +import systems.lupine.sheaf.ui.components.PENDING_DELETE_ALPHA +import systems.lupine.sheaf.ui.components.PendingDeleteBadge @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -110,7 +113,9 @@ fun PollsScreen( @Composable private fun PollRow(poll: PollRead, onClick: () -> Unit) { ListItem( - modifier = Modifier.clickable(onClick = onClick), + modifier = Modifier + .clickable(onClick = onClick) + .alpha(if (poll.pendingDeleteAt != null) PENDING_DELETE_ALPHA else 1f), headlineContent = { Text(poll.question) }, supportingContent = { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { @@ -126,6 +131,7 @@ private fun PollRow(poll: PollRead, onClick: () -> Unit) { color = if (poll.isClosed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.tertiary, ) + PendingDeleteBadge(poll.pendingDeleteAt) } }, leadingContent = { diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/tags/TagsManagerScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/tags/TagsManagerScreen.kt index ef13dc5..15c910e 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/tags/TagsManagerScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/tags/TagsManagerScreen.kt @@ -25,6 +25,9 @@ import systems.lupine.sheaf.ui.components.ColorPicker import systems.lupine.sheaf.ui.components.ColorSwatch import systems.lupine.sheaf.ui.components.ErrorBanner import systems.lupine.sheaf.ui.components.SheafTopAppBar +import androidx.compose.ui.draw.alpha +import systems.lupine.sheaf.ui.components.PENDING_DELETE_ALPHA +import systems.lupine.sheaf.ui.components.PendingDeleteBadge private const val DEFAULT_NEW_COLOR = "#10B981" @@ -149,9 +152,11 @@ private fun TagRow( onDelete: () -> Unit, ) { ListItem( + modifier = Modifier.alpha(if (tag.pendingDeleteAt != null) PENDING_DELETE_ALPHA else 1f), headlineContent = { Text(tag.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + supportingContent = tag.pendingDeleteAt?.let { { PendingDeleteBadge(it) } }, leadingContent = { ColorSwatch(hex = tag.color ?: "#10B981", size = 24.dp) }, diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/ui/components/PendingDeleteTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/components/PendingDeleteTest.kt new file mode 100644 index 0000000..b42fed6 --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/components/PendingDeleteTest.kt @@ -0,0 +1,67 @@ +package systems.lupine.sheaf.ui.components + +import java.time.OffsetDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PendingDeleteTest { + + private fun inHours(h: Long) = OffsetDateTime.now().plusHours(h) + private fun inMinutes(m: Long) = OffsetDateTime.now().plusMinutes(m) + + @Test fun `a null or blank timestamp parses to null`() { + // Every row drops the badge in unconditionally, so "not pending" has to + // be a quiet null rather than an exception. + assertNull(parseFinalizeAt(null)) + assertNull(parseFinalizeAt("")) + assertNull(parseFinalizeAt(" ")) + } + + @Test fun `an unparseable timestamp is null rather than a crash`() { + // Server-shaped data we didn't expect shouldn't take down a list. + assertNull(parseFinalizeAt("not a date")) + assertNull(parseFinalizeAt("2026-13-45T99:99:99Z")) + } + + @Test fun `a real timestamp parses`() { + assertEquals( + OffsetDateTime.parse("2026-08-01T12:00:00Z"), + parseFinalizeAt("2026-08-01T12:00:00Z"), + ) + } + + @Test fun `under a day reads in hours`() { + assertEquals("in 18h", formatFinalizeCountdown(inHours(18).plusMinutes(1))) + assertEquals("in 1h", formatFinalizeCountdown(inMinutes(61))) + } + + @Test fun `never overstates the time left`() { + // Erring long on a destructive-action deadline can cost someone the + // window to cancel; erring short only makes them act sooner. So every + // boundary rounds down. + assertEquals("in 1h", formatFinalizeCountdown(inMinutes(119))) + assertEquals("in 1 day", formatFinalizeCountdown(inHours(25))) + assertEquals("in 2 days", formatFinalizeCountdown(inHours(71))) + } + + @Test fun `the last hour says so in words rather than in 0h`() { + // Flooring to "in 0h" would read as already gone while the entity is + // still very much cancellable. + assertEquals("in under an hour", formatFinalizeCountdown(inMinutes(5))) + assertEquals("in under an hour", formatFinalizeCountdown(inMinutes(59))) + } + + @Test fun `a day or more reads in days`() { + assertEquals("in 1 day", formatFinalizeCountdown(inHours(24).plusMinutes(1))) + assertEquals("in 2 days", formatFinalizeCountdown(inHours(48).plusMinutes(1))) + assertEquals("in 7 days", formatFinalizeCountdown(inHours(24 * 7).plusMinutes(1))) + } + + @Test fun `an elapsed deadline reads as imminent, never negative`() { + // The finalise sweep runs on its own schedule, so a row can outlive its + // own deadline briefly. "in -3h" would be nonsense. + assertEquals("any moment", formatFinalizeCountdown(inHours(-3))) + assertEquals("any moment", formatFinalizeCountdown(OffsetDateTime.now())) + } +}