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