OXYGENHUB
# 0ca942a9a1e4 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / flow / FlowScreens.kt
12.1 KiB · last changed Approve, reject and expedite from the phone: a job waiting for a person shows Approve and Reject, a live run has Expedite with its reason (refs #5) 8 days ago source blame history raw
1
package io.oxygenhub.android.ui.flow
2
3
import androidx.compose.material.icons.outlined.Bolt
4
import androidx.compose.material3.Button
5
import androidx.compose.material3.OutlinedButton
6
import androidx.compose.material3.TextButton
7
import androidx.compose.material3.AlertDialog
8
import androidx.compose.material3.OutlinedTextField
9
import androidx.compose.runtime.saveable.rememberSaveable
10
import androidx.compose.runtime.mutableStateOf
11
import androidx.compose.runtime.setValue
12
import androidx.compose.foundation.clickable
13
import androidx.compose.foundation.horizontalScroll
14
import androidx.compose.foundation.layout.Arrangement
15
import androidx.compose.foundation.layout.Column
16
import androidx.compose.foundation.layout.Row
17
import androidx.compose.foundation.layout.fillMaxSize
18
import androidx.compose.foundation.layout.fillMaxWidth
19
import androidx.compose.foundation.layout.padding
20
import androidx.compose.foundation.layout.width
21
import androidx.compose.foundation.lazy.LazyColumn
22
import androidx.compose.foundation.lazy.items
23
import androidx.compose.foundation.lazy.rememberLazyListState
24
import androidx.compose.foundation.rememberScrollState
25
import androidx.compose.foundation.text.selection.SelectionContainer
26
import androidx.compose.material.icons.Icons
27
import androidx.compose.material.icons.outlined.Block
28
import androidx.compose.material.icons.outlined.CheckCircle
29
import androidx.compose.material.icons.outlined.Error
30
import androidx.compose.material.icons.outlined.HourglassEmpty
31
import androidx.compose.material.icons.outlined.PlayCircle
32
import androidx.compose.material.icons.outlined.RemoveCircleOutline
33
import androidx.compose.material.icons.outlined.Replay
34
import androidx.compose.material.icons.outlined.RocketLaunch
35
import androidx.compose.material3.HorizontalDivider
36
import androidx.compose.material3.Icon
37
import androidx.compose.material3.IconButton
38
import androidx.compose.material3.ListItem
39
import androidx.compose.material3.MaterialTheme
40
import androidx.compose.material3.Text
41
import androidx.compose.runtime.Composable
42
import androidx.compose.runtime.LaunchedEffect
43
import androidx.compose.runtime.getValue
44
import androidx.compose.ui.Modifier
45
import androidx.compose.ui.graphics.Color
46
import androidx.compose.ui.graphics.vector.ImageVector
47
import androidx.compose.ui.text.font.FontFamily
48
import androidx.compose.ui.unit.dp
49
import androidx.lifecycle.compose.collectAsStateWithLifecycle
50
import io.oxygenhub.android.api.FlowJob
51
import io.oxygenhub.android.api.FlowRun
52
import io.oxygenhub.android.ui.common.AppScreen
53
import io.oxygenhub.android.ui.common.EmptyState
54
import io.oxygenhub.android.ui.common.ErrorState
55
import io.oxygenhub.android.ui.common.Load
56
import io.oxygenhub.android.ui.common.LoadContent
57
import io.oxygenhub.android.ui.common.LoadingState
58
import io.oxygenhub.android.ui.common.relativeTime
59
import io.oxygenhub.android.ui.theme.LocalStatusColors
60
61
/** A status's icon and colour, as the hub's run pages show them. */
62
@Composable
63
fun statusLook(status: String): Pair<ImageVector, Color> {
64
    val c = LocalStatusColors.current
65
    return when (status) {
66
        "passed" -> Icons.Outlined.CheckCircle to c.ok
67
        "failed", "error" -> Icons.Outlined.Error to c.bad
68
        "running" -> Icons.Outlined.PlayCircle to c.warn
69
        "cancelled" -> Icons.Outlined.Block to MaterialTheme.colorScheme.onSurfaceVariant
70
        "skipped" -> Icons.Outlined.RemoveCircleOutline to MaterialTheme.colorScheme.onSurfaceVariant
71
        else -> Icons.Outlined.HourglassEmpty to MaterialTheme.colorScheme.onSurfaceVariant
72
    }
73
}
74
75
/** A repository's runs, for its tab. */
76
@Composable
77
fun RunList(vm: RunsViewModel, onOpen: (FlowRun) -> Unit) {
78
    val state by vm.state.collectAsStateWithLifecycle()
79
    LoadContent(state, onRefresh = vm::refresh) { runs ->
80
        if (runs.isEmpty()) {
81
            EmptyState(Icons.Outlined.RocketLaunch, "No runs yet", "A push to ${vm.owner}/${vm.name} with a .flow/pipeline.kdl starts one.")
82
        } else {
83
            LazyColumn(Modifier.fillMaxSize()) {
84
                items(runs, key = { it.number }) { r ->
85
                    val (icon, colour) = statusLook(r.status)
86
                    ListItem(
87
                        modifier = Modifier.clickable { onOpen(r) },
88
                        leadingContent = { Icon(icon, contentDescription = r.status, tint = colour) },
89
                        headlineContent = { Text("#${r.number} ${r.bookmark}") },
90
                        supportingContent = {
91
                            Text(
92
                                listOfNotNull(
93
                                    r.status,
94
                                    "${r.trigger} by ${r.actor}",
95
                                    relativeTime(r.createdMs).takeIf { it.isNotEmpty() },
96
                                    r.queuePosition?.let { "#$it in the queue" },
97
                                ).joinToString(" · "),
98
                            )
99
                        },
100
                    )
101
                    HorizontalDivider()
102
                }
103
            }
104
        }
105
    }
106
}
107
108
/** A run: its jobs, refreshed while it is live; rerun or cancel it. */
109
@Composable
110
fun RunScreen(vm: RunViewModel, canWrite: Boolean, onBack: () -> Unit, onOpenJob: (FlowJob) -> Unit) {
111
    val state by vm.state.collectAsStateWithLifecycle()
112
    val error by vm.error.collectAsStateWithLifecycle()
113
    val run = (state as? Load.Ready)?.value
114
    var expediting by rememberSaveable { mutableStateOf(false) }
115
    AppScreen(
116
        title = "Run #${vm.number}",
117
        subtitle = listOfNotNull("${vm.owner}/${vm.name}", run?.bookmark, run?.status).joinToString(" · "),
118
        onBack = onBack,
119
        actions = {
120
            if (canWrite && run != null) {
121
                if (run.live) {
122
                    IconButton(onClick = { expediting = true }) { Icon(Icons.Outlined.Bolt, contentDescription = "Expedite the run") }
123
                    IconButton(onClick = vm::cancel) { Icon(Icons.Outlined.Block, contentDescription = "Cancel the run") }
124
                } else {
125
                    IconButton(onClick = vm::rerun) { Icon(Icons.Outlined.Replay, contentDescription = "Run it again") }
126
                }
127
            }
128
        },
129
    ) {
130
        LoadContent(state, onRefresh = vm::refresh) { r ->
131
            LazyColumn(Modifier.fillMaxSize()) {
132
                if (r.error.isNotEmpty()) item { Text(r.error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) }
133
                error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) } }
134
                items(r.jobs, key = { it.name }) { j ->
135
                    val (icon, colour) = statusLook(j.status)
136
                    ListItem(
137
                        modifier = Modifier.clickable { onOpenJob(j) },
138
                        leadingContent = { Icon(icon, contentDescription = j.status, tint = colour) },
139
                        headlineContent = { Text(j.name) },
140
                        supportingContent = {
141
                            Column {
142
                                Text(
143
                                    listOfNotNull(
144
                                        j.status,
145
                                        j.seconds()?.let { "$it s" },
146
                                        j.runner.takeIf { it.isNotEmpty() }?.let { "on $it" },
147
                                        j.exitCode?.let { "exit $it" },
148
                                    ).joinToString(" · "),
149
                                )
150
                                if (j.waiting.isNotEmpty()) Text(j.waiting, style = MaterialTheme.typography.bodySmall)
151
                                // A job that waits for a person (#5): the hub's rules decide who may.
152
                                if (canWrite && j.waitsForPerson) {
153
                                    Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
154
                                        Button(onClick = { vm.decide(j.name, approve = true) }) { Text("Approve") }
155
                                        OutlinedButton(onClick = { vm.decide(j.name, approve = false) }) { Text("Reject") }
156
                                    }
157
                                }
158
                                if (j.error.isNotEmpty()) Text(j.error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
159
                            }
160
                        },
161
                        trailingContent = if (canWrite && !j.live && !r.live) {
162
                            { IconButton(onClick = { vm.rerunJob(j.name) }) { Icon(Icons.Outlined.Replay, contentDescription = "Run ${j.name} again") } }
163
                        } else {
164
                            null
165
                        },
166
                    )
167
                    HorizontalDivider()
168
                }
169
            }
170
        }
171
    }
172
    if (expediting) {
173
        ExpediteDialog(onDismiss = { expediting = false }, onExpedite = { reason -> expediting = false; vm.expedite(reason) })
174
    }
175
}
176
177
/** Make a run urgent, saying why (#5): a maintainer's, within the owner's cap of urgent runs. */
178
@Composable
179
private fun ExpediteDialog(onDismiss: () -> Unit, onExpedite: (String) -> Unit) {
180
    var reason by rememberSaveable { mutableStateOf("") }
181
    AlertDialog(
182
        onDismissRequest = onDismiss,
183
        title = { Text("Expedite") },
184
        text = {
185
            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
186
                Text("It goes ahead of everyday work until it ends. Say why: it is shown on the run and audited.", style = MaterialTheme.typography.bodySmall)
187
                OutlinedTextField(value = reason, onValueChange = { reason = it.take(200) }, label = { Text("Why it is urgent") }, singleLine = true)
188
            }
189
        },
190
        confirmButton = { TextButton(onClick = { onExpedite(reason) }, enabled = reason.isNotBlank()) { Text("Expedite") } },
191
        dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
192
    )
193
}
194
195
/** A job's log, following new lines while it runs. */
196
@Composable
197
fun JobLogScreen(vm: JobLogViewModel, onBack: () -> Unit) {
198
    val state by vm.state.collectAsStateWithLifecycle()
199
    val list = rememberLazyListState()
200
    val status = LocalStatusColors.current
201
    val log = (state as? Load.Ready)?.value
202
    // Follow the end while the job runs.
203
    LaunchedEffect(log?.lines?.size) {
204
        if (log != null && log.live && log.lines.isNotEmpty()) list.scrollToItem(log.lines.size - 1)
205
    }
206
    AppScreen(title = vm.job, subtitle = listOfNotNull("${vm.owner}/${vm.name} #${vm.number}", log?.status).joinToString(" · "), onBack = onBack) {
207
        when (val s = state) {
208
            Load.Loading -> LoadingState()
209
            is Load.Failed -> ErrorState(s.message, onRetry = vm::retry)
210
            is Load.Ready -> SelectionContainer {
211
                LazyColumn(Modifier.fillMaxSize().horizontalScroll(rememberScrollState()), state = list) {
212
                    items(s.value.lines, key = { it.seq }) { l ->
213
                        Row(Modifier.padding(horizontal = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
214
                            Text(
215
                                "%.1fs".format(l.t / 1000.0),
216
                                modifier = Modifier.width(56.dp),
217
                                fontFamily = FontFamily.Monospace,
218
                                style = MaterialTheme.typography.bodySmall,
219
                                color = MaterialTheme.colorScheme.outline,
220
                            )
221
                            Text(
222
                                l.text,
223
                                fontFamily = FontFamily.Monospace,
224
                                style = MaterialTheme.typography.bodySmall,
225
                                softWrap = false,
226
                                color = when (l.stream) {
227
                                    "stderr" -> status.bad
228
                                    "flow" -> MaterialTheme.colorScheme.primary
229
                                    else -> MaterialTheme.colorScheme.onSurface
230
                                },
231
                            )
232
                        }
233
                    }
234
                    if (s.value.live) item { Text("…", modifier = Modifier.fillMaxWidth().padding(8.dp)) }
235
                }
236
            }
237
        }
238
    }
239
}