| 1 |
package io.oxygenhub.android.ui.dashboard |
| 2 |
|
| 3 |
import androidx.compose.foundation.clickable |
| 4 |
import androidx.compose.foundation.layout.Arrangement |
| 5 |
import androidx.compose.foundation.layout.BoxWithConstraints |
| 6 |
import androidx.compose.foundation.layout.Column |
| 7 |
import androidx.compose.foundation.layout.Row |
| 8 |
import androidx.compose.foundation.layout.Spacer |
| 9 |
import androidx.compose.foundation.layout.fillMaxSize |
| 10 |
import androidx.compose.foundation.layout.fillMaxWidth |
| 11 |
import androidx.compose.foundation.layout.height |
| 12 |
import androidx.compose.foundation.layout.padding |
| 13 |
import androidx.compose.foundation.rememberScrollState |
| 14 |
import androidx.compose.foundation.verticalScroll |
| 15 |
import androidx.compose.material.icons.Icons |
| 16 |
import androidx.compose.material.icons.automirrored.outlined.CallMerge |
| 17 |
import androidx.compose.material.icons.outlined.EditNote |
| 18 |
import androidx.compose.material.icons.outlined.Inbox |
| 19 |
import androidx.compose.material.icons.outlined.MarkEmailUnread |
| 20 |
import androidx.compose.material.icons.outlined.Search |
| 21 |
import androidx.compose.material.icons.outlined.ViewKanban |
| 22 |
import androidx.compose.material3.Card |
| 23 |
import androidx.compose.material3.CardDefaults |
| 24 |
import androidx.compose.material3.HorizontalDivider |
| 25 |
import androidx.compose.material3.Icon |
| 26 |
import androidx.compose.material3.IconButton |
| 27 |
import androidx.compose.material3.ListItem |
| 28 |
import androidx.compose.material3.ListItemDefaults |
| 29 |
import androidx.compose.material3.MaterialTheme |
| 30 |
import androidx.compose.material3.Text |
| 31 |
import androidx.compose.material3.TextButton |
| 32 |
import androidx.compose.runtime.Composable |
| 33 |
import androidx.compose.runtime.getValue |
| 34 |
import androidx.compose.ui.Alignment |
| 35 |
import androidx.compose.ui.Modifier |
| 36 |
import androidx.compose.ui.graphics.Color |
| 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.Inbox |
| 42 |
import io.oxygenhub.android.api.ProposalBrief |
| 43 |
import io.oxygenhub.android.api.Repository |
| 44 |
import io.oxygenhub.android.ui.backdrop.Backdrop |
| 45 |
import io.oxygenhub.android.ui.backdrop.skyScene |
| 46 |
import io.oxygenhub.android.ui.common.AppScreen |
| 47 |
import io.oxygenhub.android.ui.common.AvatarImage |
| 48 |
import io.oxygenhub.android.ui.common.Load |
| 49 |
import io.oxygenhub.android.ui.common.LoadContent |
| 50 |
import io.oxygenhub.android.ui.common.relativeTime |
| 51 |
import io.oxygenhub.android.ui.repos.RepositoryRow |
| 52 |
import io.oxygenhub.android.ui.theme.LocalStatusColors |
| 53 |
|
| 54 |
@Composable |
| 55 |
fun DashboardScreen( |
| 56 |
vm: DashboardViewModel, |
| 57 |
hub: String, |
| 58 |
inbox: Load<Inbox>, |
| 59 |
onRefreshInbox: () -> Unit, |
| 60 |
onOpenRepository: (Repository) -> Unit, |
| 61 |
onAllRepositories: () -> Unit, |
| 62 |
onInbox: () -> Unit, |
| 63 |
onOpenProposal: (ProposalBrief) -> Unit = {}, |
| 64 |
onSearch: (() -> Unit)? = null, |
| 65 |
/** Projects, when the bar has no room for it (a phone, #14). */ |
| 66 |
onProjects: (() -> Unit)? = null, |
| 67 |
) { |
| 68 |
val state by vm.state.collectAsStateWithLifecycle() |
| 69 |
AppScreen( |
| 70 |
title = "Dashboard", |
| 71 |
subtitle = hub.substringAfter("://"), |
| 72 |
actions = { |
| 73 |
if (onProjects != null) { |
| 74 |
IconButton(onClick = onProjects) { Icon(Icons.Outlined.ViewKanban, contentDescription = "Projects") } |
| 75 |
} |
| 76 |
if (onSearch != null) { |
| 77 |
IconButton(onClick = onSearch) { Icon(Icons.Outlined.Search, contentDescription = "Search issues and proposals") } |
| 78 |
} |
| 79 |
}, |
| 80 |
) { |
| 81 |
LoadContent(state, onRefresh = { vm.refresh(); onRefreshInbox() }) { d -> |
| 82 |
// Your sky behind it, as on the hub's dashboard (#23): a finger draws it near and names a repository. |
| 83 |
Backdrop(key = d.sky, make = { still -> skyScene(d.sky, still) }, modifier = Modifier.fillMaxSize()) { |
| 84 |
DashboardContent(d, inbox, onOpenRepository, onAllRepositories, onInbox, onOpenProposal) |
| 85 |
} |
| 86 |
} |
| 87 |
} |
| 88 |
} |
| 89 |
|
| 90 |
@Composable |
| 91 |
fun DashboardContent( |
| 92 |
d: Dashboard, |
| 93 |
inbox: Load<Inbox>, |
| 94 |
onOpenRepository: (Repository) -> Unit, |
| 95 |
onAllRepositories: () -> Unit, |
| 96 |
onInbox: () -> Unit, |
| 97 |
onOpenProposal: (ProposalBrief) -> Unit = {}, |
| 98 |
) { |
| 99 |
BoxWithConstraints { |
| 100 |
// Side by side once there is room (a tablet, or a foldable open). |
| 101 |
val wide = maxWidth >= 720.dp |
| 102 |
Column( |
| 103 |
Modifier |
| 104 |
.fillMaxWidth() |
| 105 |
.verticalScroll(rememberScrollState()) |
| 106 |
.padding(16.dp), |
| 107 |
) { |
| 108 |
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { |
| 109 |
AvatarImage(d.avatar, d.username, size = 48.dp) |
| 110 |
Text(d.greeting, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) |
| 111 |
} |
| 112 |
Spacer(Modifier.height(16.dp)) |
| 113 |
if (wide) { |
| 114 |
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { |
| 115 |
Column(Modifier.weight(2f), verticalArrangement = Arrangement.spacedBy(16.dp)) { |
| 116 |
Proposals(d, onOpenProposal) |
| 117 |
Recent(d, onOpenRepository, onAllRepositories) |
| 118 |
} |
| 119 |
Column(Modifier.weight(1f)) { Unread(inbox, onInbox) } |
| 120 |
} |
| 121 |
} else { |
| 122 |
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { |
| 123 |
Unread(inbox, onInbox) |
| 124 |
Proposals(d, onOpenProposal) |
| 125 |
Recent(d, onOpenRepository, onAllRepositories) |
| 126 |
} |
| 127 |
} |
| 128 |
} |
| 129 |
} |
| 130 |
} |
| 131 |
|
| 132 |
/** Proposals waiting for the account's review, then its own: each opens where it can be landed. */ |
| 133 |
@Composable |
| 134 |
private fun Proposals(d: Dashboard, onOpen: (ProposalBrief) -> Unit) { |
| 135 |
val p = d.proposals ?: return |
| 136 |
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { |
| 137 |
if (p.review.isNotEmpty()) ProposalCard("Waiting for your review", p.review, onOpen) |
| 138 |
if (p.authored.isNotEmpty()) ProposalCard("Your proposals", p.authored, onOpen) |
| 139 |
} |
| 140 |
} |
| 141 |
|
| 142 |
@Composable |
| 143 |
private fun ProposalCard(title: String, list: List<ProposalBrief>, onOpen: (ProposalBrief) -> Unit) { |
| 144 |
Card(Modifier.fillMaxWidth(), colors = glass()) { |
| 145 |
Text( |
| 146 |
"$title · ${list.size}", |
| 147 |
style = MaterialTheme.typography.titleMedium, |
| 148 |
modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 4.dp), |
| 149 |
) |
| 150 |
list.forEachIndexed { i, b -> |
| 151 |
if (i > 0) HorizontalDivider() |
| 152 |
ListItem( |
| 153 |
modifier = Modifier.clickable { onOpen(b) }, |
| 154 |
colors = ListItemDefaults.colors(containerColor = Color.Transparent), |
| 155 |
leadingContent = { |
| 156 |
Icon( |
| 157 |
if (b.state == "draft") Icons.Outlined.EditNote else Icons.AutoMirrored.Outlined.CallMerge, |
| 158 |
contentDescription = b.state, |
| 159 |
tint = if (b.state == "draft") MaterialTheme.colorScheme.onSurfaceVariant else LocalStatusColors.current.ok, |
| 160 |
) |
| 161 |
}, |
| 162 |
headlineContent = { Text(b.title, maxLines = 2, overflow = TextOverflow.Ellipsis) }, |
| 163 |
supportingContent = { |
| 164 |
val by = if (title.startsWith("Waiting")) " by ${b.author}" else "" |
| 165 |
val conflicts = if (b.conflicted) " · conflicts" else "" |
| 166 |
Text("${b.repository} #${b.number}$by · ${relativeTime(b.updatedMs)}$conflicts", maxLines = 1, overflow = TextOverflow.Ellipsis) |
| 167 |
}, |
| 168 |
) |
| 169 |
} |
| 170 |
} |
| 171 |
} |
| 172 |
|
| 173 |
@Composable |
| 174 |
private fun Unread(inbox: Load<Inbox>, onInbox: () -> Unit) { |
| 175 |
Card(Modifier.fillMaxWidth().clickable(onClick = onInbox), colors = glass()) { |
| 176 |
ListItem( |
| 177 |
colors = ListItemDefaults.colors(containerColor = Color.Transparent), |
| 178 |
leadingContent = { |
| 179 |
Icon(if ((inbox.valueOrNull?.unread ?: 0) > 0) Icons.Outlined.MarkEmailUnread else Icons.Outlined.Inbox, contentDescription = null) |
| 180 |
}, |
| 181 |
headlineContent = { |
| 182 |
Text( |
| 183 |
when (inbox) { |
| 184 |
Load.Loading -> "Inbox" |
| 185 |
is Load.Failed -> "Inbox unavailable" |
| 186 |
is Load.Ready -> when (val n = inbox.value.unread) { |
| 187 |
0L -> "Nothing unread" |
| 188 |
1L -> "1 unread notification" |
| 189 |
else -> "$n unread notifications" |
| 190 |
} |
| 191 |
}, |
| 192 |
) |
| 193 |
}, |
| 194 |
supportingContent = (inbox as? Load.Failed)?.let { f -> { Text(f.message) } }, |
| 195 |
) |
| 196 |
} |
| 197 |
} |
| 198 |
|
| 199 |
@Composable |
| 200 |
private fun Recent(d: Dashboard, onOpenRepository: (Repository) -> Unit, onAllRepositories: () -> Unit) { |
| 201 |
Card(Modifier.fillMaxWidth(), colors = glass()) { |
| 202 |
Text( |
| 203 |
if (d.recentAreYours) "Your recent repositories" else "Recently updated on the hub", |
| 204 |
style = MaterialTheme.typography.titleMedium, |
| 205 |
modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp), |
| 206 |
) |
| 207 |
if (!d.recentAreYours) { |
| 208 |
Text( |
| 209 |
"You have no repositories of your own yet.", |
| 210 |
style = MaterialTheme.typography.bodySmall, |
| 211 |
color = MaterialTheme.colorScheme.onSurfaceVariant, |
| 212 |
modifier = Modifier.padding(horizontal = 16.dp), |
| 213 |
) |
| 214 |
} |
| 215 |
if (d.recent.isEmpty()) { |
| 216 |
Text("No repositories to show.", modifier = Modifier.padding(16.dp)) |
| 217 |
} |
| 218 |
d.recent.forEachIndexed { i, repo -> |
| 219 |
if (i > 0) HorizontalDivider() |
| 220 |
RepositoryRow(repo, onClick = { onOpenRepository(repo) }) |
| 221 |
} |
| 222 |
TextButton(onClick = onAllRepositories, modifier = Modifier.padding(8.dp)) { Text("All repositories") } |
| 223 |
} |
| 224 |
} |
| 225 |
|
| 226 |
/** A card the sky shows through a little. */ |
| 227 |
@Composable |
| 228 |
private fun glass() = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.8f)) |