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