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