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