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