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