OXYGENHUB
# 8440d51a2275 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / proposals / ProposalScreen.kt
16.2 KiB · last changed A proposal's description renders as Markdown (closes #29) 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 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
}