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())) + } +}