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