OXYGENHUB
# 1823bc12eda1 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / proposals / ProposalScreen.kt
16.2 KiB · last changed Panels you can see: the hub's palette by default, clear surface steps and a 1 dp edge on every panel (closes #24) 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
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
    Panel(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
        Panel(Modifier.fillMaxWidth()) { content() }
349
    }
350
}