| 1 |
package io.oxygenhub.android.ui.inbox |
| 2 |
|
| 3 |
import androidx.compose.foundation.clickable |
| 4 |
import androidx.compose.foundation.layout.Box |
| 5 |
import androidx.compose.foundation.layout.fillMaxSize |
| 6 |
import androidx.compose.foundation.layout.size |
| 7 |
import androidx.compose.foundation.lazy.LazyColumn |
| 8 |
import androidx.compose.foundation.lazy.items |
| 9 |
import androidx.compose.foundation.shape.CircleShape |
| 10 |
import androidx.compose.material.icons.Icons |
| 11 |
import androidx.compose.material.icons.automirrored.outlined.CallMerge |
| 12 |
import androidx.compose.material.icons.outlined.AlternateEmail |
| 13 |
import androidx.compose.material.icons.outlined.Backup |
| 14 |
import androidx.compose.material.icons.outlined.BugReport |
| 15 |
import androidx.compose.material.icons.outlined.ChatBubbleOutline |
| 16 |
import androidx.compose.material.icons.outlined.DoneAll |
| 17 |
import androidx.compose.material.icons.outlined.Inbox |
| 18 |
import androidx.compose.material.icons.outlined.Notifications |
| 19 |
import androidx.compose.material.icons.outlined.PlayCircle |
| 20 |
import androidx.compose.material.icons.outlined.RateReview |
| 21 |
import androidx.compose.material3.HorizontalDivider |
| 22 |
import androidx.compose.material3.Icon |
| 23 |
import androidx.compose.material3.IconButton |
| 24 |
import androidx.compose.material3.ListItem |
| 25 |
import androidx.compose.material3.ListItemDefaults |
| 26 |
import androidx.compose.material3.MaterialTheme |
| 27 |
import androidx.compose.material3.SnackbarHostState |
| 28 |
import androidx.compose.material3.Surface |
| 29 |
import androidx.compose.material3.Text |
| 30 |
import androidx.compose.runtime.Composable |
| 31 |
import androidx.compose.runtime.LaunchedEffect |
| 32 |
import androidx.compose.runtime.getValue |
| 33 |
import androidx.compose.runtime.remember |
| 34 |
import androidx.compose.ui.Alignment |
| 35 |
import androidx.compose.ui.Modifier |
| 36 |
import androidx.compose.ui.graphics.vector.ImageVector |
| 37 |
import androidx.compose.ui.text.font.FontWeight |
| 38 |
import androidx.compose.ui.text.style.TextOverflow |
| 39 |
import androidx.compose.ui.unit.dp |
| 40 |
import androidx.lifecycle.compose.collectAsStateWithLifecycle |
| 41 |
import io.oxygenhub.android.api.Notification |
| 42 |
import io.oxygenhub.android.ui.common.AppScreen |
| 43 |
import io.oxygenhub.android.ui.common.EmptyState |
| 44 |
import io.oxygenhub.android.ui.common.LoadContent |
| 45 |
import io.oxygenhub.android.ui.common.relativeTime |
| 46 |
import io.oxygenhub.android.ui.theme.Tones |
| 47 |
|
| 48 |
/** |
| 49 |
* The inbox. A notification opens what it is about on its own screen in |
| 50 |
* the app ([onOpen]): an issue, proposal, run, change, file or board; the |
| 51 |
* browser only for a page the app does not show. |
| 52 |
*/ |
| 53 |
@Composable |
| 54 |
fun InboxScreen(vm: InboxViewModel, onOpen: (url: String) -> Unit) { |
| 55 |
val state by vm.state.collectAsStateWithLifecycle() |
| 56 |
val message by vm.message.collectAsStateWithLifecycle() |
| 57 |
val snackbar = remember { SnackbarHostState() } |
| 58 |
LaunchedEffect(message) { |
| 59 |
message?.let { |
| 60 |
snackbar.showSnackbar(it) |
| 61 |
vm.messageShown() |
| 62 |
} |
| 63 |
} |
| 64 |
val unread = state.valueOrNull?.unread ?: 0 |
| 65 |
AppScreen( |
| 66 |
title = "Inbox", |
| 67 |
subtitle = if (unread > 0) "$unread unread" else null, |
| 68 |
snackbar = snackbar, |
| 69 |
actions = { |
| 70 |
if (unread > 0) { |
| 71 |
IconButton(onClick = vm::markAllRead) { Icon(Icons.Outlined.DoneAll, contentDescription = "Mark all read") } |
| 72 |
} |
| 73 |
}, |
| 74 |
) { |
| 75 |
LoadContent(state, onRefresh = vm::refresh) { inbox -> |
| 76 |
if (inbox.items.isEmpty()) { |
| 77 |
EmptyState(Icons.Outlined.Inbox, "Your inbox is empty", "Mentions, reviews and activity on what you watch arrive here.") |
| 78 |
} else { |
| 79 |
LazyColumn(Modifier.fillMaxSize()) { |
| 80 |
items(inbox.items, key = { it.id }) { n -> |
| 81 |
NotificationRow(n, onClick = { |
| 82 |
vm.markRead(n) |
| 83 |
if (n.url.isNotBlank()) onOpen(n.url) |
| 84 |
}) |
| 85 |
HorizontalDivider() |
| 86 |
} |
| 87 |
} |
| 88 |
} |
| 89 |
} |
| 90 |
} |
| 91 |
} |
| 92 |
|
| 93 |
@Composable |
| 94 |
fun NotificationRow(n: Notification, onClick: () -> Unit) { |
| 95 |
ListItem( |
| 96 |
modifier = Modifier.clickable(onClick = onClick), |
| 97 |
colors = if (n.read) ListItemDefaults.colors() else ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), |
| 98 |
leadingContent = { |
| 99 |
Box(contentAlignment = Alignment.TopEnd) { |
| 100 |
Icon(iconFor(n.kind), contentDescription = null, tint = Tones.of(n.kind)) |
| 101 |
if (!n.read) { |
| 102 |
Surface(Modifier.size(8.dp), shape = CircleShape, color = MaterialTheme.colorScheme.primary) {} |
| 103 |
} |
| 104 |
} |
| 105 |
}, |
| 106 |
headlineContent = { |
| 107 |
Text(n.title, maxLines = 2, overflow = TextOverflow.Ellipsis, fontWeight = if (n.read) FontWeight.Normal else FontWeight.SemiBold) |
| 108 |
}, |
| 109 |
supportingContent = { |
| 110 |
val who = if (n.actor.isNotBlank()) "${n.actor} · " else "" |
| 111 |
Text("$who${describe(n.kind)} · ${relativeTime(n.atMs)}", maxLines = 1, overflow = TextOverflow.Ellipsis) |
| 112 |
}, |
| 113 |
) |
| 114 |
} |
| 115 |
|
| 116 |
private fun iconFor(kind: String): ImageVector = when { |
| 117 |
kind == "mention" -> Icons.Outlined.AlternateEmail |
| 118 |
kind.startsWith("proposal.review") -> Icons.Outlined.RateReview |
| 119 |
kind.endsWith(".comment") -> Icons.Outlined.ChatBubbleOutline |
| 120 |
kind.startsWith("proposal") -> Icons.AutoMirrored.Outlined.CallMerge |
| 121 |
kind.startsWith("issue") -> Icons.Outlined.BugReport |
| 122 |
kind.startsWith("flow") -> Icons.Outlined.PlayCircle |
| 123 |
kind.startsWith("backup") -> Icons.Outlined.Backup |
| 124 |
else -> Icons.Outlined.Notifications |
| 125 |
} |
| 126 |
|
| 127 |
/** |
| 128 |
* What a kind of notification is called, as the hub's emails name them |
| 129 |
* (oxygenhub crates/hub/src/notify/mod.rs, `label`), without the emoji. |
| 130 |
*/ |
| 131 |
internal fun describe(kind: String): String = when (kind) { |
| 132 |
"flow.failed" -> "Build broken" |
| 133 |
"flow.fixed" -> "Build fixed" |
| 134 |
"flow.runner_drained" -> "Runner drained" |
| 135 |
"backup" -> "Backup failed" |
| 136 |
"backup.recovered" -> "Backup recovered" |
| 137 |
"backup.summary" -> "Daily backup summary" |
| 138 |
"proposal.opened" -> "New proposal" |
| 139 |
"proposal.review_requested" -> "Review requested" |
| 140 |
"proposal.review" -> "New review" |
| 141 |
"proposal.landed" -> "Proposal landed" |
| 142 |
"proposal.comment", "issue.comment", "drop.comment" -> "New comment" |
| 143 |
"issue.opened" -> "New issue" |
| 144 |
"issue.assigned" -> "Issue assigned" |
| 145 |
"issue.closed" -> "Issue closed" |
| 146 |
"account.registered" -> "New sign-up" |
| 147 |
"mention" -> "Mentioned you" |
| 148 |
else -> kind.replace('.', ' ').replace('_', ' ').replaceFirstChar { it.uppercase() } |
| 149 |
} |