| 1 |
package io.oxygenhub.android.ui.proposals |
| 2 |
|
| 3 |
import androidx.compose.foundation.clickable |
| 4 |
import androidx.compose.foundation.layout.Arrangement |
| 5 |
import androidx.compose.foundation.layout.Box |
| 6 |
import androidx.compose.foundation.layout.Column |
| 7 |
import androidx.compose.foundation.layout.FlowRow |
| 8 |
import androidx.compose.foundation.layout.Row |
| 9 |
import androidx.compose.foundation.layout.Spacer |
| 10 |
import androidx.compose.foundation.layout.fillMaxWidth |
| 11 |
import androidx.compose.foundation.layout.padding |
| 12 |
import androidx.compose.foundation.layout.size |
| 13 |
import androidx.compose.foundation.layout.width |
| 14 |
import androidx.compose.foundation.layout.widthIn |
| 15 |
import androidx.compose.foundation.rememberScrollState |
| 16 |
import androidx.compose.foundation.verticalScroll |
| 17 |
import androidx.compose.material.icons.Icons |
| 18 |
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight |
| 19 |
import androidx.compose.material.icons.automirrored.outlined.OpenInNew |
| 20 |
import androidx.compose.material.icons.outlined.Cancel |
| 21 |
import androidx.compose.material.icons.outlined.CheckCircle |
| 22 |
import androidx.compose.material.icons.outlined.Lock |
| 23 |
import androidx.compose.material.icons.outlined.RemoveCircleOutline |
| 24 |
import androidx.compose.material.icons.outlined.Schedule |
| 25 |
import androidx.compose.material.icons.outlined.WarningAmber |
| 26 |
import androidx.compose.material3.AlertDialog |
| 27 |
import androidx.compose.material3.Button |
| 28 |
import androidx.compose.material3.CircularProgressIndicator |
| 29 |
import androidx.compose.material3.HorizontalDivider |
| 30 |
import androidx.compose.material3.Icon |
| 31 |
import androidx.compose.material3.IconButton |
| 32 |
import androidx.compose.material3.LinearProgressIndicator |
| 33 |
import androidx.compose.material3.ListItem |
| 34 |
import androidx.compose.material3.MaterialTheme |
| 35 |
import androidx.compose.material3.OutlinedButton |
| 36 |
import androidx.compose.material3.OutlinedTextField |
| 37 |
import androidx.compose.material3.SnackbarHostState |
| 38 |
import androidx.compose.material3.Switch |
| 39 |
import androidx.compose.material3.Text |
| 40 |
import androidx.compose.material3.TextButton |
| 41 |
import androidx.compose.runtime.Composable |
| 42 |
import androidx.compose.runtime.LaunchedEffect |
| 43 |
import androidx.compose.runtime.getValue |
| 44 |
import androidx.compose.runtime.mutableStateOf |
| 45 |
import androidx.compose.runtime.remember |
| 46 |
import androidx.compose.runtime.saveable.rememberSaveable |
| 47 |
import androidx.compose.runtime.setValue |
| 48 |
import androidx.compose.ui.Alignment |
| 49 |
import androidx.compose.ui.Modifier |
| 50 |
import androidx.compose.ui.platform.LocalUriHandler |
| 51 |
import androidx.compose.ui.text.font.FontFamily |
| 52 |
import androidx.compose.ui.text.font.FontWeight |
| 53 |
import androidx.compose.ui.unit.dp |
| 54 |
import androidx.lifecycle.compose.collectAsStateWithLifecycle |
| 55 |
import com.mikepenz.markdown.m3.Markdown |
| 56 |
import io.oxygenhub.android.api.Check |
| 57 |
import io.oxygenhub.android.api.Proposal |
| 58 |
import io.oxygenhub.android.ui.common.AppScreen |
| 59 |
import io.oxygenhub.android.ui.common.LoadContent |
| 60 |
import io.oxygenhub.android.ui.common.NoImages |
| 61 |
import io.oxygenhub.android.ui.common.Panel |
| 62 |
import io.oxygenhub.android.ui.common.relativeTime |
| 63 |
import io.oxygenhub.android.ui.issues.LabelChip |
| 64 |
import io.oxygenhub.android.ui.theme.LocalStatusColors |
| 65 |
|
| 66 |
/** |
| 67 |
* One proposal: whether it may land and what stands in the way, in the |
| 68 |
* hub's words; landing it, or having it land when its checks pass; then |
| 69 |
* its checks, approvals, changes and description. |
| 70 |
*/ |
| 71 |
@Composable |
| 72 |
fun ProposalScreen(vm: ProposalViewModel, canWrite: Boolean, onBack: () -> Unit, onSignInAgain: () -> Unit, onOpenFiles: () -> Unit = {}) { |
| 73 |
val state by vm.state.collectAsStateWithLifecycle() |
| 74 |
val action by vm.action.collectAsStateWithLifecycle() |
| 75 |
val snackbar = remember { SnackbarHostState() } |
| 76 |
val uri = LocalUriHandler.current |
| 77 |
val done = action as? LandAction.Done |
| 78 |
LaunchedEffect(done) { |
| 79 |
if (done != null) { |
| 80 |
snackbar.showSnackbar(done.message) |
| 81 |
vm.dismiss() |
| 82 |
} |
| 83 |
} |
| 84 |
AppScreen( |
| 85 |
title = "#${vm.number}", |
| 86 |
subtitle = "${vm.owner}/${vm.name}", |
| 87 |
onBack = onBack, |
| 88 |
snackbar = snackbar, |
| 89 |
actions = { |
| 90 |
val url = state.valueOrNull?.proposal?.url.orEmpty() |
| 91 |
if (url.isNotBlank()) { |
| 92 |
IconButton(onClick = { uri.openUri(url) }) { |
| 93 |
Icon(Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = "Open on the hub") |
| 94 |
} |
| 95 |
} |
| 96 |
}, |
| 97 |
) { |
| 98 |
LoadContent(state, onRefresh = vm::refresh) { page -> |
| 99 |
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { |
| 100 |
Column( |
| 101 |
Modifier.widthIn(max = 720.dp).verticalScroll(rememberScrollState()).padding(16.dp), |
| 102 |
verticalArrangement = Arrangement.spacedBy(16.dp), |
| 103 |
) { |
| 104 |
Header(page.proposal) |
| 105 |
Landing(page.proposal, action, canWrite, vm::land, vm::setAutoLand, onSignInAgain, vm::dismiss) |
| 106 |
Checks(page.checks) { if (it.url.isNotBlank()) uri.openUri(it.url) } |
| 107 |
Reviews(page.proposal, canReview = canWrite && page.proposal.state == "open", busy = action == LandAction.Working, onReview = vm::review) |
| 108 |
Changes(page.proposal, onOpenFiles) |
| 109 |
if (page.proposal.body.isNotBlank()) { |
| 110 |
Section("Description") { |
| 111 |
Markdown(page.proposal.body, modifier = Modifier.padding(16.dp), imageTransformer = NoImages) |
| 112 |
} |
| 113 |
} |
| 114 |
} |
| 115 |
} |
| 116 |
} |
| 117 |
} |
| 118 |
} |
| 119 |
|
| 120 |
@Composable |
| 121 |
private fun Header(p: Proposal) { |
| 122 |
val (icon, tint) = stateLook(p) |
| 123 |
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { |
| 124 |
Row(verticalAlignment = Alignment.CenterVertically) { |
| 125 |
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(18.dp)) |
| 126 |
Spacer(Modifier.width(6.dp)) |
| 127 |
Text(p.state.replaceFirstChar { it.uppercase() }, color = tint, style = MaterialTheme.typography.labelLarge) |
| 128 |
} |
| 129 |
Text(p.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) |
| 130 |
val n = p.changes.size |
| 131 |
Text( |
| 132 |
"${p.author} wants to land $n change${if (n == 1) "" else "s"} into ${p.target} · updated ${relativeTime(p.updatedMs)}", |
| 133 |
style = MaterialTheme.typography.bodyMedium, |
| 134 |
color = MaterialTheme.colorScheme.onSurfaceVariant, |
| 135 |
) |
| 136 |
if (p.labels.isNotEmpty()) { |
| 137 |
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { |
| 138 |
p.labels.forEach { LabelChip(it) } |
| 139 |
} |
| 140 |
} |
| 141 |
} |
| 142 |
} |
| 143 |
|
| 144 |
@Composable |
| 145 |
private fun Landing( |
| 146 |
p: Proposal, |
| 147 |
action: LandAction, |
| 148 |
canWrite: Boolean, |
| 149 |
onLand: () -> Unit, |
| 150 |
onAutoLand: (Boolean) -> Unit, |
| 151 |
onSignInAgain: () -> Unit, |
| 152 |
onDismiss: () -> Unit, |
| 153 |
) { |
| 154 |
val status = LocalStatusColors.current |
| 155 |
var confirming by rememberSaveable { mutableStateOf(false) } |
| 156 |
val working = action == LandAction.Working |
| 157 |
Panel(Modifier.fillMaxWidth()) { |
| 158 |
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { |
| 159 |
when { |
| 160 |
p.landed != null -> Text( |
| 161 |
"Landed ${relativeTime(p.landed.whenMs)} by ${p.landed.by}${if (p.landed.via.isNotBlank()) " (${p.landed.via})" else ""}.", |
| 162 |
style = MaterialTheme.typography.titleMedium, |
| 163 |
) |
| 164 |
!p.isOpen -> Text("Closed without landing.", style = MaterialTheme.typography.titleMedium) |
| 165 |
else -> { |
| 166 |
if (p.landable) { |
| 167 |
Text("Ready to land", style = MaterialTheme.typography.titleMedium, color = status.ok) |
| 168 |
} else { |
| 169 |
Text("Not ready to land", style = MaterialTheme.typography.titleMedium) |
| 170 |
p.blockers.forEach { b -> |
| 171 |
Row(verticalAlignment = Alignment.Top) { |
| 172 |
Icon(Icons.Outlined.WarningAmber, contentDescription = null, tint = status.bad, modifier = Modifier.size(18.dp).padding(top = 2.dp)) |
| 173 |
Spacer(Modifier.width(8.dp)) |
| 174 |
Text(b, style = MaterialTheme.typography.bodyMedium) |
| 175 |
} |
| 176 |
} |
| 177 |
} |
| 178 |
if (!canWrite) { |
| 179 |
Text( |
| 180 |
"This phone was signed in to read only. Sign in again to allow landing.", |
| 181 |
style = MaterialTheme.typography.bodyMedium, |
| 182 |
color = MaterialTheme.colorScheme.onSurfaceVariant, |
| 183 |
) |
| 184 |
OutlinedButton(onClick = onSignInAgain) { Text("Sign in again") } |
| 185 |
} else { |
| 186 |
Button(onClick = { confirming = true }, enabled = p.landable && !working, modifier = Modifier.fillMaxWidth()) { |
| 187 |
Text("Land into ${p.target}") |
| 188 |
} |
| 189 |
Row(verticalAlignment = Alignment.CenterVertically) { |
| 190 |
Column(Modifier.weight(1f)) { |
| 191 |
Text("Land when its checks pass", style = MaterialTheme.typography.bodyLarge) |
| 192 |
Text( |
| 193 |
p.autoLand?.let { "Set by $it" } ?: "Lands by itself once nothing stands in the way.", |
| 194 |
style = MaterialTheme.typography.bodySmall, |
| 195 |
color = MaterialTheme.colorScheme.onSurfaceVariant, |
| 196 |
) |
| 197 |
} |
| 198 |
Switch(checked = p.autoLand != null, onCheckedChange = onAutoLand, enabled = !working && !p.isDraft) |
| 199 |
} |
| 200 |
} |
| 201 |
} |
| 202 |
} |
| 203 |
if (working) LinearProgressIndicator(Modifier.fillMaxWidth()) |
| 204 |
if (action is LandAction.Failed) { |
| 205 |
Text(action.message, color = status.bad, style = MaterialTheme.typography.bodyMedium) |
| 206 |
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| 207 |
if (action.needsWrite) OutlinedButton(onClick = onSignInAgain) { Text("Sign in again") } |
| 208 |
TextButton(onClick = onDismiss) { Text("OK") } |
| 209 |
} |
| 210 |
} |
| 211 |
} |
| 212 |
} |
| 213 |
if (confirming) { |
| 214 |
AlertDialog( |
| 215 |
onDismissRequest = { confirming = false }, |
| 216 |
title = { Text("Land #${p.number}?") }, |
| 217 |
text = { Text("“${p.title}” lands into ${p.target} on the hub, as LAND on its page does.") }, |
| 218 |
confirmButton = { |
| 219 |
TextButton(onClick = { |
| 220 |
confirming = false |
| 221 |
onLand() |
| 222 |
}) { Text("Land") } |
| 223 |
}, |
| 224 |
dismissButton = { TextButton(onClick = { confirming = false }) { Text("Cancel") } }, |
| 225 |
) |
| 226 |
} |
| 227 |
} |
| 228 |
|
| 229 |
@Composable |
| 230 |
private fun Checks(checks: List<Check>?, onOpen: (Check) -> Unit) { |
| 231 |
val status = LocalStatusColors.current |
| 232 |
Section( |
| 233 |
when { |
| 234 |
checks == null -> "Checks" |
| 235 |
checks.isEmpty() -> "Checks" |
| 236 |
else -> "Checks · ${checks.count { it.status == "passed" }} of ${checks.size} passed" |
| 237 |
}, |
| 238 |
) { |
| 239 |
when { |
| 240 |
checks == null -> Text("The checks could not be read.", Modifier.padding(16.dp)) |
| 241 |
checks.isEmpty() -> Text("No checks have run on what it would land.", Modifier.padding(16.dp)) |
| 242 |
else -> checks.sortedBy { order(it.status) }.forEachIndexed { i, c -> |
| 243 |
if (i > 0) HorizontalDivider() |
| 244 |
ListItem( |
| 245 |
modifier = Modifier.clickable { onOpen(c) }, |
| 246 |
leadingContent = { |
| 247 |
when (c.status) { |
| 248 |
"passed" -> Icon(Icons.Outlined.CheckCircle, "Passed", tint = status.ok) |
| 249 |
"failed" -> Icon(Icons.Outlined.Cancel, "Failed", tint = status.bad) |
| 250 |
"running" -> CircularProgressIndicator(Modifier.size(22.dp), strokeWidth = 2.dp) |
| 251 |
"queued" -> Icon(Icons.Outlined.Schedule, "Queued", tint = MaterialTheme.colorScheme.onSurfaceVariant) |
| 252 |
else -> Icon(Icons.Outlined.RemoveCircleOutline, c.status, tint = MaterialTheme.colorScheme.onSurfaceVariant) |
| 253 |
} |
| 254 |
}, |
| 255 |
headlineContent = { Text(c.name) }, |
| 256 |
supportingContent = if (c.summary.isNotBlank()) ({ Text(c.summary) }) else null, |
| 257 |
) |
| 258 |
} |
| 259 |
} |
| 260 |
} |
| 261 |
} |
| 262 |
|
| 263 |
/** Failures first, then what is still going, then the rest. */ |
| 264 |
private fun order(status: String): Int = when (status) { |
| 265 |
"failed" -> 0 |
| 266 |
"running" -> 1 |
| 267 |
"queued" -> 2 |
| 268 |
else -> 3 |
| 269 |
} |
| 270 |
|
| 271 |
@Composable |
| 272 |
private fun Reviews(p: Proposal, canReview: Boolean, busy: Boolean, onReview: (verdict: String, body: String) -> Unit) { |
| 273 |
if (p.approvals.isEmpty() && p.requestedReviewers.isEmpty() && !canReview) return |
| 274 |
Section("Reviews") { |
| 275 |
Column(Modifier.padding(vertical = 4.dp)) { |
| 276 |
if (canReview) ReviewBox(busy, onReview) |
| 277 |
p.approvals.forEach { a -> |
| 278 |
ListItem( |
| 279 |
leadingContent = { Icon(Icons.Outlined.CheckCircle, null, tint = LocalStatusColors.current.ok) }, |
| 280 |
headlineContent = { Text(a.account) }, |
| 281 |
supportingContent = { Text(if (a.sealed) "approved, sealed" else "approved, not sealed: it does not count yet") }, |
| 282 |
trailingContent = if (a.sealed) ({ Icon(Icons.Outlined.Lock, "Sealed") }) else null, |
| 283 |
) |
| 284 |
} |
| 285 |
p.requestedReviewers.forEach { r -> |
| 286 |
ListItem( |
| 287 |
leadingContent = { Icon(Icons.Outlined.Schedule, null) }, |
| 288 |
headlineContent = { Text(r) }, |
| 289 |
supportingContent = { Text("asked to review") }, |
| 290 |
) |
| 291 |
} |
| 292 |
} |
| 293 |
} |
| 294 |
} |
| 295 |
|
| 296 |
/** |
| 297 |
* Review it from the phone (#4): approve, ask for changes, or comment. An |
| 298 |
* approval from here is not sealed (the phone holds no key of yours), so |
| 299 |
* where the repository needs sealed approvals it does not count yet. |
| 300 |
*/ |
| 301 |
@Composable |
| 302 |
private fun ReviewBox(busy: Boolean, onReview: (verdict: String, body: String) -> Unit) { |
| 303 |
var text by rememberSaveable { mutableStateOf("") } |
| 304 |
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { |
| 305 |
OutlinedTextField( |
| 306 |
value = text, |
| 307 |
onValueChange = { text = it }, |
| 308 |
label = { Text("Review") }, |
| 309 |
placeholder = { Text("What you think; needed to ask for changes") }, |
| 310 |
modifier = Modifier.fillMaxWidth(), |
| 311 |
minLines = 2, |
| 312 |
enabled = !busy, |
| 313 |
) |
| 314 |
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| 315 |
Button(onClick = { onReview("approve", text); text = "" }, enabled = !busy) { Text("Approve") } |
| 316 |
OutlinedButton(onClick = { onReview("changes", text); text = "" }, enabled = !busy && text.isNotBlank()) { Text("Request changes") } |
| 317 |
TextButton(onClick = { onReview("comment", text); text = "" }, enabled = !busy && text.isNotBlank()) { Text("Comment") } |
| 318 |
} |
| 319 |
} |
| 320 |
} |
| 321 |
|
| 322 |
@Composable |
| 323 |
private fun Changes(p: Proposal, onOpenFiles: () -> Unit) { |
| 324 |
if (p.changes.isEmpty()) return |
| 325 |
Section("Changes · ${p.changes.size}") { |
| 326 |
Column { |
| 327 |
// Its files and their diffs, as the proposal page shows them (#4). |
| 328 |
ListItem( |
| 329 |
modifier = Modifier.clickable(onClick = onOpenFiles), |
| 330 |
headlineContent = { Text("Files changed") }, |
| 331 |
supportingContent = { Text("Each file with its diff") }, |
| 332 |
trailingContent = { Icon(Icons.AutoMirrored.Outlined.KeyboardArrowRight, contentDescription = null) }, |
| 333 |
) |
| 334 |
HorizontalDivider() |
| 335 |
p.changes.asReversed().forEachIndexed { i, c -> |
| 336 |
if (i > 0) HorizontalDivider() |
| 337 |
ListItem( |
| 338 |
headlineContent = { Text(c.subject.ifBlank { "(no message)" }) }, |
| 339 |
supportingContent = { Text(c.hash.take(12), fontFamily = FontFamily.Monospace) }, |
| 340 |
) |
| 341 |
} |
| 342 |
} |
| 343 |
} |
| 344 |
} |
| 345 |
|
| 346 |
@Composable |
| 347 |
private fun Section(title: String, content: @Composable () -> Unit) { |
| 348 |
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { |
| 349 |
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) |
| 350 |
Panel(Modifier.fillMaxWidth()) { content() } |
| 351 |
} |
| 352 |
} |