Flow, at a glance; New repository with an assistant to start it (closes #26, closes #27, refs andymccall/oxygenhub#347) 821035db5a1c
A Flow destination: your repositories' runs newest first over a band of their sky, live/passed/failed counts, filters, pulsing status, job dots, refreshing every 5 s while anything runs (GET /user/flow/runs). Repositories' + makes a repository under you or an organisation; with an assistant set up for the owner, 'Describe what you want to create…' asks it for a starting point and follows the draft (POST/GET /repos/O/N/starter). The app asks for repo:admin at sign-in; an older sign-in is told to sign in again.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key b862f6b96b0b
- When
- · 2026-10-28 17:04 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
821035db5a1ce273332ae8ae606352320210d5a75c2672e6551a535ff376503a- Depends on
- 0a2858c0dad3 144cbf7bf2b5 1c42a70fcba3 1dfa631436b6 2a15621eca66 31e4f3755627 57c0824300db 5ae4af48ddd2 62429abe5869 6e2af63ae616 6fcaca2bd500 75ceba747960 840c75813f86 d98e5a147428 dfbc3e972872 e2df27a81e5d
- Depended on by
- 263c6cad5e51 b610bc6eeab7
- In bookmarks
- main
- In tags
- v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
- Edits
- 31 atoms, 3 tree operations, 11 files
Browse the tree at this change
added app/src/main/java/io/oxygenhub/android/ui/flow/FlowMonitor.kt +192 −0
| 1 | + | package io.oxygenhub.android.ui.flow |
|
| 2 | + | ||
| 3 | + | import androidx.compose.foundation.Canvas |
|
| 4 | + | import androidx.compose.foundation.clickable |
|
| 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.height |
|
| 11 | + | import androidx.compose.foundation.layout.padding |
|
| 12 | + | import androidx.compose.foundation.lazy.LazyColumn |
|
| 13 | + | import androidx.compose.foundation.lazy.items |
|
| 14 | + | import androidx.compose.material.icons.Icons |
|
| 15 | + | import androidx.compose.material.icons.outlined.RocketLaunch |
|
| 16 | + | import androidx.compose.material3.FilterChip |
|
| 17 | + | import androidx.compose.material3.MaterialTheme |
|
| 18 | + | import androidx.compose.material3.Text |
|
| 19 | + | import androidx.compose.runtime.Composable |
|
| 20 | + | import androidx.compose.runtime.LaunchedEffect |
|
| 21 | + | import androidx.compose.runtime.getValue |
|
| 22 | + | import androidx.compose.runtime.mutableStateOf |
|
| 23 | + | import androidx.compose.runtime.saveable.rememberSaveable |
|
| 24 | + | import androidx.compose.runtime.setValue |
|
| 25 | + | import androidx.compose.ui.Alignment |
|
| 26 | + | import androidx.compose.ui.Modifier |
|
| 27 | + | import androidx.compose.ui.graphics.Color |
|
| 28 | + | import androidx.compose.ui.text.font.FontWeight |
|
| 29 | + | import androidx.compose.ui.text.style.TextOverflow |
|
| 30 | + | import androidx.compose.ui.unit.dp |
|
| 31 | + | import androidx.lifecycle.ViewModel |
|
| 32 | + | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 33 | + | import io.oxygenhub.android.api.FlowRun |
|
| 34 | + | import io.oxygenhub.android.api.HubApi |
|
| 35 | + | import io.oxygenhub.android.api.UserFlowRun |
|
| 36 | + | import io.oxygenhub.android.api.UserSky |
|
| 37 | + | import io.oxygenhub.android.ui.backdrop.Backdrop |
|
| 38 | + | import io.oxygenhub.android.ui.backdrop.skyScene |
|
| 39 | + | import io.oxygenhub.android.ui.common.AppScreen |
|
| 40 | + | import io.oxygenhub.android.ui.common.EmptyState |
|
| 41 | + | import io.oxygenhub.android.ui.common.Load |
|
| 42 | + | import io.oxygenhub.android.ui.common.LoadContent |
|
| 43 | + | import io.oxygenhub.android.ui.common.Panel |
|
| 44 | + | import io.oxygenhub.android.ui.common.load |
|
| 45 | + | import io.oxygenhub.android.ui.common.panelColors |
|
| 46 | + | import io.oxygenhub.android.ui.common.relativeTime |
|
| 47 | + | import io.oxygenhub.android.ui.theme.LocalStatusColors |
|
| 48 | + | import kotlinx.coroutines.Job |
|
| 49 | + | import kotlinx.coroutines.delay |
|
| 50 | + | import kotlinx.coroutines.flow.MutableStateFlow |
|
| 51 | + | import kotlinx.coroutines.flow.StateFlow |
|
| 52 | + | import kotlinx.coroutines.flow.asStateFlow |
|
| 53 | + | ||
| 54 | + | /** The runs of your repositories, newest first (#26), from the hub's `GET /user/flow/runs`. */ |
|
| 55 | + | class FlowMonitorViewModel(private val api: HubApi) : ViewModel() { |
|
| 56 | + | private val _state = MutableStateFlow<Load<List<UserFlowRun>>>(Load.Loading) |
|
| 57 | + | val state: StateFlow<Load<List<UserFlowRun>>> = _state.asStateFlow() |
|
| 58 | + | private var job: Job? = null |
|
| 59 | + | ||
| 60 | + | init { |
|
| 61 | + | refresh() |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | fun refresh() { |
|
| 65 | + | job = load(_state, job) { api.userFlowRuns() } |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | /** Whether any run shown is still going: the screen then refreshes often. */ |
|
| 69 | + | val live: Boolean get() = (_state.value as? Load.Ready)?.value?.any { it.run.live } == true |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | /** What the monitor shows. */ |
|
| 73 | + | enum class RunFilter(val label: String) { All("All"), Live("Live"), Failed("Failed") } |
|
| 74 | + | ||
| 75 | + | internal fun filtered(runs: List<UserFlowRun>, f: RunFilter): List<UserFlowRun> = when (f) { |
|
| 76 | + | RunFilter.All -> runs |
|
| 77 | + | RunFilter.Live -> runs.filter { it.run.live } |
|
| 78 | + | RunFilter.Failed -> runs.filter { it.run.status == "failed" || it.run.status == "error" } |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | /** The runs as Your sky draws Flow runs: each repository a cluster, each run a mark (kind 4). */ |
|
| 82 | + | internal fun runSky(runs: List<UserFlowRun>, now: Long): UserSky { |
|
| 83 | + | val repos = runs.map { it.repository }.distinct() |
|
| 84 | + | return UserSky(now = now, repos = repos, events = runs.map { listOf(repos.indexOf(it.repository).toLong(), 4L, it.run.createdMs, 1L) }) |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | /** How long a run took, or has taken so far: "3 min 12 s". */ |
|
| 88 | + | internal fun took(r: FlowRun, now: Long): String { |
|
| 89 | + | if (r.createdMs <= 0) return "" |
|
| 90 | + | val s = (((r.finishedMs ?: now) - r.createdMs) / 1000).coerceAtLeast(0) |
|
| 91 | + | return when { |
|
| 92 | + | s < 60 -> "$s s" |
|
| 93 | + | s < 3600 -> "${s / 60} min ${s % 60} s" |
|
| 94 | + | else -> "${s / 3600} h ${(s % 3600) / 60} min" |
|
| 95 | + | } |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | @Composable |
|
| 99 | + | fun FlowMonitorScreen(vm: FlowMonitorViewModel, onOpenRun: (owner: String, name: String, number: Long) -> Unit) { |
|
| 100 | + | val state by vm.state.collectAsStateWithLifecycle() |
|
| 101 | + | var filter by rememberSaveable { mutableStateOf(RunFilter.All) } |
|
| 102 | + | // Live while anything runs; a quiet look now and then otherwise. Only while shown. |
|
| 103 | + | LaunchedEffect(vm) { |
|
| 104 | + | while (true) { |
|
| 105 | + | delay(if (vm.live) 5_000 else 30_000) |
|
| 106 | + | vm.refresh() |
|
| 107 | + | } |
|
| 108 | + | } |
|
| 109 | + | AppScreen(title = "Flow", subtitle = "your repositories' runs") { |
|
| 110 | + | LoadContent(state, onRefresh = vm::refresh) { runs -> |
|
| 111 | + | if (runs.isEmpty()) { |
|
| 112 | + | EmptyState(Icons.Outlined.RocketLaunch, "No runs yet", "A push to one of your repositories with a .flow/pipeline.kdl starts one.") |
|
| 113 | + | return@LoadContent |
|
| 114 | + | } |
|
| 115 | + | val now = System.currentTimeMillis() |
|
| 116 | + | val shown = filtered(runs, filter) |
|
| 117 | + | LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 118 | + | item(key = "sky") { |
|
| 119 | + | Backdrop(key = runs.map { it.repository to it.run.number }, make = { still -> skyScene(runSky(runs, now), still) }, modifier = Modifier.fillMaxWidth().height(120.dp)) { |
|
| 120 | + | Tally(runs, Modifier.align(Alignment.BottomStart).padding(16.dp)) |
|
| 121 | + | } |
|
| 122 | + | } |
|
| 123 | + | item(key = "filters") { |
|
| 124 | + | Row(Modifier.padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 125 | + | RunFilter.entries.forEach { f -> FilterChip(selected = filter == f, onClick = { filter = f }, label = { Text(f.label) }) } |
|
| 126 | + | } |
|
| 127 | + | } |
|
| 128 | + | if (shown.isEmpty()) { |
|
| 129 | + | item(key = "none") { Text("None ${filter.label.lowercase()} now.", Modifier.padding(16.dp), color = MaterialTheme.colorScheme.onSurfaceVariant) } |
|
| 130 | + | } |
|
| 131 | + | items(shown, key = { "${it.repository}#${it.run.number}" }) { r -> |
|
| 132 | + | RunCard(r, now, Modifier.padding(horizontal = 12.dp)) { onOpenRun(r.owner, r.name, r.run.number) } |
|
| 133 | + | } |
|
| 134 | + | item(key = "end") { Text("", Modifier.height(8.dp)) } |
|
| 135 | + | } |
|
| 136 | + | } |
|
| 137 | + | } |
|
| 138 | + | } |
|
| 139 | + | ||
| 140 | + | /** Live, passed and failed among the runs shown, big, in their colours. */ |
|
| 141 | + | @Composable |
|
| 142 | + | private fun Tally(runs: List<UserFlowRun>, modifier: Modifier = Modifier) { |
|
| 143 | + | val s = LocalStatusColors.current |
|
| 144 | + | Row(modifier, horizontalArrangement = Arrangement.spacedBy(20.dp)) { |
|
| 145 | + | Count(runs.count { it.run.live }, "live", s.warn) |
|
| 146 | + | Count(runs.count { it.run.status == "passed" }, "passed", s.ok) |
|
| 147 | + | Count(runs.count { it.run.status == "failed" || it.run.status == "error" }, "failed", s.bad) |
|
| 148 | + | } |
|
| 149 | + | } |
|
| 150 | + | ||
| 151 | + | @Composable |
|
| 152 | + | private fun Count(n: Int, what: String, colour: Color) { |
|
| 153 | + | Column { |
|
| 154 | + | Text("$n", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = colour) |
|
| 155 | + | Text(what, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) |
|
| 156 | + | } |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | @Composable |
|
| 160 | + | private fun RunCard(r: UserFlowRun, now: Long, modifier: Modifier, onClick: () -> Unit) { |
|
| 161 | + | val (icon, colour) = statusLook(r.run.status) |
|
| 162 | + | Panel(modifier.fillMaxWidth().clickable(onClick = onClick), colors = panelColors()) { |
|
| 163 | + | Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { |
|
| 164 | + | StatusIcon(r.run.status, icon, colour) |
|
| 165 | + | Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { |
|
| 166 | + | Text("${r.repository} #${r.run.number}", style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) |
|
| 167 | + | Text( |
|
| 168 | + | listOf(r.run.bookmark, r.run.trigger.substringBefore(':'), r.run.actor).filter { it.isNotBlank() }.joinToString(" · "), |
|
| 169 | + | style = MaterialTheme.typography.bodySmall, |
|
| 170 | + | color = MaterialTheme.colorScheme.onSurfaceVariant, |
|
| 171 | + | maxLines = 1, |
|
| 172 | + | overflow = TextOverflow.Ellipsis, |
|
| 173 | + | ) |
|
| 174 | + | if (r.run.jobs.isNotEmpty()) JobDots(r.run) |
|
| 175 | + | } |
|
| 176 | + | Column(horizontalAlignment = Alignment.End) { |
|
| 177 | + | Text(relativeTime(r.run.createdMs, now), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) |
|
| 178 | + | Text(took(r.run, now), style = MaterialTheme.typography.labelSmall, color = colour) |
|
| 179 | + | } |
|
| 180 | + | } |
|
| 181 | + | } |
|
| 182 | + | } |
|
| 183 | + | ||
| 184 | + | /** One dot per job, in its status's colour: the run's shape at a glance. */ |
|
| 185 | + | @Composable |
|
| 186 | + | private fun JobDots(run: FlowRun) { |
|
| 187 | + | val colours = run.jobs.map { statusLook(it.status).second } |
|
| 188 | + | Canvas(Modifier.padding(top = 4.dp).height(8.dp).fillMaxWidth()) { |
|
| 189 | + | val d = size.height |
|
| 190 | + | colours.forEachIndexed { i, c -> drawCircle(c, d / 2, androidx.compose.ui.geometry.Offset(d / 2 + i * d * 1.6f, d / 2)) } |
|
| 191 | + | } |
|
| 192 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/repos/NewRepository.kt +302 −0
| 1 | + | package io.oxygenhub.android.ui.repos |
|
| 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.layout.Arrangement |
|
| 9 | + | import androidx.compose.foundation.layout.Column |
|
| 10 | + | import androidx.compose.foundation.layout.Row |
|
| 11 | + | import androidx.compose.foundation.layout.fillMaxWidth |
|
| 12 | + | import androidx.compose.foundation.layout.imePadding |
|
| 13 | + | import androidx.compose.foundation.layout.padding |
|
| 14 | + | import androidx.compose.foundation.rememberScrollState |
|
| 15 | + | import androidx.compose.foundation.verticalScroll |
|
| 16 | + | import androidx.compose.material.icons.Icons |
|
| 17 | + | import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile |
|
| 18 | + | import androidx.compose.material.icons.outlined.AutoAwesome |
|
| 19 | + | import androidx.compose.material3.Button |
|
| 20 | + | import androidx.compose.material3.FilterChip |
|
| 21 | + | import androidx.compose.material3.Icon |
|
| 22 | + | import androidx.compose.material3.LinearProgressIndicator |
|
| 23 | + | import androidx.compose.material3.ListItem |
|
| 24 | + | import androidx.compose.material3.MaterialTheme |
|
| 25 | + | import androidx.compose.material3.OutlinedTextField |
|
| 26 | + | import androidx.compose.material3.Text |
|
| 27 | + | import androidx.compose.material3.TextButton |
|
| 28 | + | import androidx.compose.runtime.Composable |
|
| 29 | + | import androidx.compose.runtime.LaunchedEffect |
|
| 30 | + | import androidx.compose.runtime.getValue |
|
| 31 | + | import androidx.compose.runtime.mutableStateOf |
|
| 32 | + | import androidx.compose.runtime.saveable.rememberSaveable |
|
| 33 | + | import androidx.compose.runtime.setValue |
|
| 34 | + | import androidx.compose.ui.Modifier |
|
| 35 | + | import androidx.compose.ui.draw.alpha |
|
| 36 | + | import androidx.compose.ui.unit.dp |
|
| 37 | + | import androidx.lifecycle.ViewModel |
|
| 38 | + | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 39 | + | import androidx.lifecycle.viewModelScope |
|
| 40 | + | import io.oxygenhub.android.api.HubApi |
|
| 41 | + | import io.oxygenhub.android.api.HubException |
|
| 42 | + | import io.oxygenhub.android.api.Repository |
|
| 43 | + | import io.oxygenhub.android.api.StarterAssistant |
|
| 44 | + | import io.oxygenhub.android.api.Visibility |
|
| 45 | + | import io.oxygenhub.android.api.userMessage |
|
| 46 | + | import io.oxygenhub.android.ui.common.AppScreen |
|
| 47 | + | import io.oxygenhub.android.ui.common.Panel |
|
| 48 | + | import io.oxygenhub.android.ui.theme.Tones |
|
| 49 | + | import kotlinx.coroutines.CancellationException |
|
| 50 | + | import kotlinx.coroutines.Job |
|
| 51 | + | import kotlinx.coroutines.delay |
|
| 52 | + | import kotlinx.coroutines.flow.MutableStateFlow |
|
| 53 | + | import kotlinx.coroutines.flow.StateFlow |
|
| 54 | + | import kotlinx.coroutines.flow.asStateFlow |
|
| 55 | + | import kotlinx.coroutines.launch |
|
| 56 | + | ||
| 57 | + | /** Where making a repository stands (#27): its starter drafted by an assistant when asked (oxygenhub#347). */ |
|
| 58 | + | sealed interface Making { |
|
| 59 | + | data object Idle : Making |
|
| 60 | + | data object Creating : Making |
|
| 61 | + | data class Drafting(val repo: Repository, val assistant: String) : Making |
|
| 62 | + | data class Done(val repo: Repository, val files: List<String>, val assistant: String) : Making |
|
| 63 | + | data class Failed(val message: String, val repo: Repository? = null) : Making |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | /** A new repository: its owner (you or an organisation you belong to), name, description, visibility, and what to start it with. */ |
|
| 67 | + | class NewRepositoryViewModel( |
|
| 68 | + | private val api: HubApi, |
|
| 69 | + | val username: String, |
|
| 70 | + | private val pollMs: Long = 2_000, |
|
| 71 | + | private val pollFor: Int = 180, |
|
| 72 | + | ) : ViewModel() { |
|
| 73 | + | private val _owners = MutableStateFlow(listOf(username)) |
|
| 74 | + | val owners: StateFlow<List<String>> = _owners.asStateFlow() |
|
| 75 | + | ||
| 76 | + | private val _assistants = MutableStateFlow<List<StarterAssistant>>(emptyList()) |
|
| 77 | + | val assistants: StateFlow<List<StarterAssistant>> = _assistants.asStateFlow() |
|
| 78 | + | ||
| 79 | + | private val _making = MutableStateFlow<Making>(Making.Idle) |
|
| 80 | + | val making: StateFlow<Making> = _making.asStateFlow() |
|
| 81 | + | ||
| 82 | + | private var asking: Job? = null |
|
| 83 | + | ||
| 84 | + | init { |
|
| 85 | + | viewModelScope.launch { |
|
| 86 | + | try { |
|
| 87 | + | _owners.value = listOf(username) + api.userOrgs().map { it.name }.filter { it != username } |
|
| 88 | + | } catch (e: CancellationException) { |
|
| 89 | + | throw e |
|
| 90 | + | } catch (_: Exception) { |
|
| 91 | + | // Without the call, only the account itself. |
|
| 92 | + | } |
|
| 93 | + | } |
|
| 94 | + | assistantsFor(username) |
|
| 95 | + | } |
|
| 96 | + | ||
| 97 | + | /** Which assistants a repository under [owner] may ask; none when the hub cannot say. */ |
|
| 98 | + | fun assistantsFor(owner: String) { |
|
| 99 | + | asking?.cancel() |
|
| 100 | + | asking = viewModelScope.launch { |
|
| 101 | + | _assistants.value = try { |
|
| 102 | + | api.starterAssistants(owner) |
|
| 103 | + | } catch (e: CancellationException) { |
|
| 104 | + | throw e |
|
| 105 | + | } catch (_: Exception) { |
|
| 106 | + | emptyList() |
|
| 107 | + | } |
|
| 108 | + | } |
|
| 109 | + | } |
|
| 110 | + | ||
| 111 | + | fun create(owner: String, name: String, description: String, visibility: Visibility, ask: String) { |
|
| 112 | + | if (_making.value == Making.Creating || name.isBlank()) return |
|
| 113 | + | _making.value = Making.Creating |
|
| 114 | + | viewModelScope.launch { |
|
| 115 | + | val repo = try { |
|
| 116 | + | api.createRepository(owner, name.trim(), description.trim(), visibility) |
|
| 117 | + | } catch (e: CancellationException) { |
|
| 118 | + | throw e |
|
| 119 | + | } catch (e: HubException.Http) { |
|
| 120 | + | _making.value = Making.Failed( |
|
| 121 | + | if (e.status == 403) "${e.userMessage()} (making repositories needs this phone to be allowed repo:admin: sign out and in again to allow it)" else e.userMessage(), |
|
| 122 | + | ) |
|
| 123 | + | return@launch |
|
| 124 | + | } catch (e: Exception) { |
|
| 125 | + | _making.value = Making.Failed(e.userMessage()) |
|
| 126 | + | return@launch |
|
| 127 | + | } |
|
| 128 | + | val assistant = _assistants.value.firstOrNull() |
|
| 129 | + | if (ask.isBlank() || assistant == null) { |
|
| 130 | + | _making.value = Making.Done(repo, emptyList(), "") |
|
| 131 | + | return@launch |
|
| 132 | + | } |
|
| 133 | + | draft(repo, ask.trim(), assistant.name) |
|
| 134 | + | } |
|
| 135 | + | } |
|
| 136 | + | ||
| 137 | + | private suspend fun draft(repo: Repository, ask: String, assistant: String) { |
|
| 138 | + | try { |
|
| 139 | + | api.startStarter(repo.owner, repo.name, ask) |
|
| 140 | + | } catch (e: CancellationException) { |
|
| 141 | + | throw e |
|
| 142 | + | } catch (e: Exception) { |
|
| 143 | + | _making.value = Making.Failed("The repository was made, but its starter was not: ${e.userMessage()}", repo) |
|
| 144 | + | return |
|
| 145 | + | } |
|
| 146 | + | _making.value = Making.Drafting(repo, assistant) |
|
| 147 | + | repeat(pollFor) { |
|
| 148 | + | delay(pollMs) |
|
| 149 | + | val s = try { |
|
| 150 | + | api.starterStatus(repo.owner, repo.name) |
|
| 151 | + | } catch (e: CancellationException) { |
|
| 152 | + | throw e |
|
| 153 | + | } catch (_: Exception) { |
|
| 154 | + | return@repeat |
|
| 155 | + | } |
|
| 156 | + | when (s.status) { |
|
| 157 | + | "done" -> { |
|
| 158 | + | _making.value = Making.Done(repo, s.files, s.assistant.ifBlank { assistant }) |
|
| 159 | + | return |
|
| 160 | + | } |
|
| 161 | + | "failed" -> { |
|
| 162 | + | _making.value = Making.Failed("The repository was made, but ${s.assistant.ifBlank { assistant }} could not draft it: ${s.error}", repo) |
|
| 163 | + | return |
|
| 164 | + | } |
|
| 165 | + | "none" -> { |
|
| 166 | + | _making.value = Making.Failed("The repository was made; the hub restarted while its starter was drafted, so it has no files yet.", repo) |
|
| 167 | + | return |
|
| 168 | + | } |
|
| 169 | + | } |
|
| 170 | + | } |
|
| 171 | + | _making.value = Making.Failed("The repository was made; its starter is taking long, and may still arrive.", repo) |
|
| 172 | + | } |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | @Composable |
|
| 176 | + | fun NewRepositoryScreen(vm: NewRepositoryViewModel, canCreate: Boolean, onBack: () -> Unit, onOpen: (Repository) -> Unit) { |
|
| 177 | + | val owners by vm.owners.collectAsStateWithLifecycle() |
|
| 178 | + | val assistants by vm.assistants.collectAsStateWithLifecycle() |
|
| 179 | + | val making by vm.making.collectAsStateWithLifecycle() |
|
| 180 | + | var owner by rememberSaveable { mutableStateOf(vm.username) } |
|
| 181 | + | var name by rememberSaveable { mutableStateOf("") } |
|
| 182 | + | var description by rememberSaveable { mutableStateOf("") } |
|
| 183 | + | var visibility by rememberSaveable { mutableStateOf(Visibility.Public) } |
|
| 184 | + | var ask by rememberSaveable { mutableStateOf("") } |
|
| 185 | + | LaunchedEffect(owner) { vm.assistantsFor(owner) } |
|
| 186 | + | AppScreen(title = "New repository", onBack = onBack) { |
|
| 187 | + | Column( |
|
| 188 | + | Modifier.verticalScroll(rememberScrollState()).padding(16.dp).imePadding(), |
|
| 189 | + | verticalArrangement = Arrangement.spacedBy(12.dp), |
|
| 190 | + | ) { |
|
| 191 | + | when (val m = making) { |
|
| 192 | + | is Making.Drafting -> { |
|
| 193 | + | Drafting(m.assistant, "${m.repo.owner}/${m.repo.name}") |
|
| 194 | + | return@Column |
|
| 195 | + | } |
|
| 196 | + | is Making.Done -> { |
|
| 197 | + | Made(m, onOpen) |
|
| 198 | + | return@Column |
|
| 199 | + | } |
|
| 200 | + | else -> {} |
|
| 201 | + | } |
|
| 202 | + | if (!canCreate) { |
|
| 203 | + | Text( |
|
| 204 | + | "This phone was signed in without repo:admin, which making a repository needs. Sign out and in again, and allow it on the hub's page.", |
|
| 205 | + | color = MaterialTheme.colorScheme.error, |
|
| 206 | + | style = MaterialTheme.typography.bodyMedium, |
|
| 207 | + | ) |
|
| 208 | + | } |
|
| 209 | + | if (owners.size > 1) { |
|
| 210 | + | Text("Owner", style = MaterialTheme.typography.labelLarge) |
|
| 211 | + | Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 212 | + | owners.forEach { o -> FilterChip(selected = owner == o, onClick = { owner = o }, label = { Text(o) }) } |
|
| 213 | + | } |
|
| 214 | + | } |
|
| 215 | + | OutlinedTextField(name, { name = it }, label = { Text("Name") }, prefix = { Text("$owner/") }, singleLine = true, modifier = Modifier.fillMaxWidth()) |
|
| 216 | + | OutlinedTextField(description, { description = it }, label = { Text("Description (optional)") }, singleLine = true, modifier = Modifier.fillMaxWidth()) |
|
| 217 | + | Text("Visibility", style = MaterialTheme.typography.labelLarge) |
|
| 218 | + | Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 219 | + | Visibility.entries.forEach { v -> |
|
| 220 | + | FilterChip(selected = visibility == v, onClick = { visibility = v }, label = { Text(v.name) }) |
|
| 221 | + | } |
|
| 222 | + | } |
|
| 223 | + | assistants.firstOrNull()?.let { a -> |
|
| 224 | + | Panel(Modifier.fillMaxWidth()) { |
|
| 225 | + | Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 226 | + | Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 227 | + | Icon(Icons.Outlined.AutoAwesome, contentDescription = null, tint = Tones.release) |
|
| 228 | + | Text("Start it with ${a.name}", style = MaterialTheme.typography.titleSmall) |
|
| 229 | + | } |
|
| 230 | + | OutlinedTextField( |
|
| 231 | + | ask, |
|
| 232 | + | { ask = it }, |
|
| 233 | + | placeholder = { Text("Describe what you want to create…") }, |
|
| 234 | + | minLines = 3, |
|
| 235 | + | modifier = Modifier.fillMaxWidth(), |
|
| 236 | + | ) |
|
| 237 | + | Text( |
|
| 238 | + | "${a.name} (${a.model}, from ${a.from}) drafts a README, build files, a source layout and a first test, committed as you. Leave it empty for an empty repository.", |
|
| 239 | + | style = MaterialTheme.typography.bodySmall, |
|
| 240 | + | color = MaterialTheme.colorScheme.onSurfaceVariant, |
|
| 241 | + | ) |
|
| 242 | + | } |
|
| 243 | + | } |
|
| 244 | + | } |
|
| 245 | + | (making as? Making.Failed)?.let { f -> |
|
| 246 | + | Text(f.message, color = MaterialTheme.colorScheme.error) |
|
| 247 | + | f.repo?.let { r -> TextButton(onClick = { onOpen(r) }) { Text("Open ${r.owner}/${r.name}") } } |
|
| 248 | + | } |
|
| 249 | + | Button( |
|
| 250 | + | enabled = canCreate && name.isNotBlank() && making != Making.Creating, |
|
| 251 | + | onClick = { vm.create(owner, name, description, visibility, ask) }, |
|
| 252 | + | modifier = Modifier.fillMaxWidth(), |
|
| 253 | + | ) { Text(if (ask.isNotBlank() && assistants.isNotEmpty()) "Create and draft" else "Create repository") } |
|
| 254 | + | if (making == Making.Creating) LinearProgressIndicator(Modifier.fillMaxWidth()) |
|
| 255 | + | } |
|
| 256 | + | } |
|
| 257 | + | } |
|
| 258 | + | ||
| 259 | + | /** An assistant at work: its name, the repository, and a breathing sparkle. */ |
|
| 260 | + | @Composable |
|
| 261 | + | private fun Drafting(assistant: String, slug: String) { |
|
| 262 | + | val pulse by rememberInfiniteTransition(label = "drafting").animateFloat( |
|
| 263 | + | 0.35f, |
|
| 264 | + | 1f, |
|
| 265 | + | infiniteRepeatable(tween(900, easing = LinearOutSlowInEasing), androidx.compose.animation.core.RepeatMode.Reverse), |
|
| 266 | + | label = "pulse", |
|
| 267 | + | ) |
|
| 268 | + | Panel(Modifier.fillMaxWidth()) { |
|
| 269 | + | Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { |
|
| 270 | + | Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 271 | + | Icon(Icons.Outlined.AutoAwesome, contentDescription = null, tint = Tones.release, modifier = Modifier.alpha(pulse)) |
|
| 272 | + | Text("$assistant is drafting $slug…", style = MaterialTheme.typography.titleSmall) |
|
| 273 | + | } |
|
| 274 | + | LinearProgressIndicator(Modifier.fillMaxWidth()) |
|
| 275 | + | Text( |
|
| 276 | + | "The repository is made; its first files arrive when the model has written them. You can leave this screen: they land on the repository either way.", |
|
| 277 | + | style = MaterialTheme.typography.bodySmall, |
|
| 278 | + | color = MaterialTheme.colorScheme.onSurfaceVariant, |
|
| 279 | + | ) |
|
| 280 | + | } |
|
| 281 | + | } |
|
| 282 | + | } |
|
| 283 | + | ||
| 284 | + | @Composable |
|
| 285 | + | private fun Made(m: Making.Done, onOpen: (Repository) -> Unit) { |
|
| 286 | + | Panel(Modifier.fillMaxWidth()) { |
|
| 287 | + | Column(Modifier.padding(vertical = 8.dp)) { |
|
| 288 | + | Text( |
|
| 289 | + | if (m.files.isEmpty()) "${m.repo.owner}/${m.repo.name} is made" else "${m.assistant} drafted ${m.files.size} files", |
|
| 290 | + | style = MaterialTheme.typography.titleMedium, |
|
| 291 | + | modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), |
|
| 292 | + | ) |
|
| 293 | + | m.files.forEach { f -> |
|
| 294 | + | ListItem( |
|
| 295 | + | leadingContent = { Icon(Icons.AutoMirrored.Outlined.InsertDriveFile, contentDescription = null, tint = Tones.info) }, |
|
| 296 | + | headlineContent = { Text(f) }, |
|
| 297 | + | ) |
|
| 298 | + | } |
|
| 299 | + | TextButton(onClick = { onOpen(m.repo) }, modifier = Modifier.padding(horizontal = 8.dp)) { Text("Open ${m.repo.owner}/${m.repo.name}") } |
|
| 300 | + | } |
|
| 301 | + | } |
|
| 302 | + | } |
added app/src/test/java/io/oxygenhub/android/ui/NewRepositoryAndFlowTest.kt +93 −0
| 1 | + | package io.oxygenhub.android.ui |
|
| 2 | + | ||
| 3 | + | import io.oxygenhub.android.FakeHubApi |
|
| 4 | + | import io.oxygenhub.android.MainDispatcherRule |
|
| 5 | + | import io.oxygenhub.android.api.FlowJob |
|
| 6 | + | import io.oxygenhub.android.api.FlowRun |
|
| 7 | + | import io.oxygenhub.android.api.HubException |
|
| 8 | + | import io.oxygenhub.android.api.StarterAssistant |
|
| 9 | + | import io.oxygenhub.android.api.StarterStatus |
|
| 10 | + | import io.oxygenhub.android.api.UserFlowRun |
|
| 11 | + | import io.oxygenhub.android.api.Visibility |
|
| 12 | + | import io.oxygenhub.android.ui.flow.RunFilter |
|
| 13 | + | import io.oxygenhub.android.ui.flow.filtered |
|
| 14 | + | import io.oxygenhub.android.ui.flow.runSky |
|
| 15 | + | import io.oxygenhub.android.ui.flow.took |
|
| 16 | + | import io.oxygenhub.android.ui.repos.Making |
|
| 17 | + | import io.oxygenhub.android.ui.repos.NewRepositoryViewModel |
|
| 18 | + | import org.junit.Assert.assertEquals |
|
| 19 | + | import org.junit.Assert.assertTrue |
|
| 20 | + | import org.junit.Rule |
|
| 21 | + | import org.junit.Test |
|
| 22 | + | ||
| 23 | + | class NewRepositoryAndFlowTest { |
|
| 24 | + | @get:Rule val main = MainDispatcherRule() |
|
| 25 | + | ||
| 26 | + | private val api = FakeHubApi() |
|
| 27 | + | ||
| 28 | + | @Test |
|
| 29 | + | fun aRepositoryIsMadeUnderYouOrAnOrganisationYouBelongTo() { |
|
| 30 | + | val vm = NewRepositoryViewModel(api, "andymccall") |
|
| 31 | + | assertEquals(listOf("andymccall", "doomsdayone"), vm.owners.value) |
|
| 32 | + | vm.create("doomsdayone", " pong ", " A game ", Visibility.Private, ask = "") |
|
| 33 | + | val done = vm.making.value as Making.Done |
|
| 34 | + | assertEquals(("doomsdayone" to "pong"), done.repo.owner to done.repo.name) |
|
| 35 | + | assertEquals(Visibility.Private, done.repo.visibility) |
|
| 36 | + | assertTrue("no starter without an assistant: ${api.calls}", api.calls.none { it.startsWith("start starter") }) |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | @Test |
|
| 40 | + | fun anAssistantDraftsTheStarterAndTheScreenFollowsIt() { |
|
| 41 | + | api.starterAssistants = { listOf(StarterAssistant("Helper", "m1", "the site")) } |
|
| 42 | + | api.starterStatuses = mutableListOf(StarterStatus("running", "Helper"), StarterStatus("done", "Helper", "abc", listOf("README.md", "src/main.rs"))) |
|
| 43 | + | val vm = NewRepositoryViewModel(api, "andymccall", pollMs = 10) |
|
| 44 | + | vm.create("andymccall", "pong", "", Visibility.Public, ask = "A pong game in Rust") |
|
| 45 | + | assertTrue(vm.making.value is Making.Drafting) |
|
| 46 | + | main.dispatcher.scheduler.advanceUntilIdle() |
|
| 47 | + | val done = vm.making.value as Making.Done |
|
| 48 | + | assertEquals(listOf("README.md", "src/main.rs"), done.files) |
|
| 49 | + | assertEquals("Helper", done.assistant) |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | @Test |
|
| 53 | + | fun aFailedDraftStillOpensTheRepositoryAndSaysWhy() { |
|
| 54 | + | api.starterAssistants = { listOf(StarterAssistant("Helper")) } |
|
| 55 | + | api.starterStatuses = mutableListOf(StarterStatus("failed", "Helper", error = "the assistant answered without any files")) |
|
| 56 | + | val vm = NewRepositoryViewModel(api, "andymccall", pollMs = 10) |
|
| 57 | + | vm.create("andymccall", "pong", "", Visibility.Public, ask = "something") |
|
| 58 | + | main.dispatcher.scheduler.advanceUntilIdle() |
|
| 59 | + | val failed = vm.making.value as Making.Failed |
|
| 60 | + | assertTrue(failed.message, failed.message.contains("without any files")) |
|
| 61 | + | assertEquals("pong", failed.repo?.name) |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | @Test |
|
| 65 | + | fun aPhoneWithoutRepoAdminIsToldHowToAllowIt() { |
|
| 66 | + | api.createRepository = { _, _, _, _ -> throw HubException.Http(403, "this token lacks the repo:admin scope") } |
|
| 67 | + | val vm = NewRepositoryViewModel(api, "andymccall") |
|
| 68 | + | vm.create("andymccall", "pong", "", Visibility.Public, ask = "") |
|
| 69 | + | val failed = vm.making.value as Making.Failed |
|
| 70 | + | assertTrue(failed.message, failed.message.contains("sign out and in again")) |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | private fun run(repo: String, n: Long, status: String, created: Long, finished: Long? = null, jobs: List<String> = emptyList()) = |
|
| 74 | + | UserFlowRun(repo, FlowRun(number = n, status = status, createdMs = created, finishedMs = finished, jobs = jobs.map { FlowJob(name = it, status = status) })) |
|
| 75 | + | ||
| 76 | + | @Test |
|
| 77 | + | fun theMonitorFiltersDrawsAndTimesRuns() { |
|
| 78 | + | val runs = listOf( |
|
| 79 | + | run("andy/a", 3, "running", 9_000), |
|
| 80 | + | run("andy/b", 2, "failed", 5_000, 8_000), |
|
| 81 | + | run("andy/a", 1, "passed", 1_000, 200_000, jobs = listOf("build", "test")), |
|
| 82 | + | ) |
|
| 83 | + | assertEquals(listOf(3L), filtered(runs, RunFilter.Live).map { it.run.number }) |
|
| 84 | + | assertEquals(listOf(2L), filtered(runs, RunFilter.Failed).map { it.run.number }) |
|
| 85 | + | assertEquals(3, filtered(runs, RunFilter.All).size) |
|
| 86 | + | val sky = runSky(runs, now = 10_000) |
|
| 87 | + | assertEquals(listOf("andy/a", "andy/b"), sky.repos) |
|
| 88 | + | assertEquals(listOf(listOf(0L, 4L, 9_000L, 1L), listOf(1L, 4L, 5_000L, 1L), listOf(0L, 4L, 1_000L, 1L)), sky.events) |
|
| 89 | + | assertEquals("3 s", took(runs[1].run, now = 10_000)) |
|
| 90 | + | assertEquals("3 min 19 s", took(runs[2].run, now = 0)) |
|
| 91 | + | assertEquals("1 s", took(runs[0].run, now = 10_000), ) |
|
| 92 | + | } |
|
| 93 | + | } |
modified CHANGELOG.md +2 −0
| … | 2 unchanged lines |
||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | 5 | - **Panels you can see (#24):** on a phone, cards and boxes were near-black on black. The app now uses OxygenHub's own colours by default (Account › Wallpaper colours turns Android's back on), every card is one panel with a 1 dp edge, as the website's have, on a tone a clear step above the background, and wallpaper colours in the dark get the same steps. A backdrop stays inside its band, and an image the app cannot show yet (a README's badge) no longer leaves a 180 dp blank. |
|
| 6 | + | - **Flow, at a glance (#26):** a Flow destination in the navigation: the runs of all your repositories, newest first, over a band of their sky (each repository a cluster, each run a mark) with how many are live, passed and failed; filters for live and failed; each run with its pulsing status, bookmark, trigger, who, when, how long, and a dot per job in its colour; tap for the run. Live runs refresh every 5 s while it is open. From OxygenHub's `GET /user/flow/runs`. |
|
| 7 | + | - **New repository, and an assistant to start it (#27, oxygenhub#347):** Repositories has a + for a new repository: under you or an organisation you belong to, with a description and visibility. When the owner has an assistant set up, "Describe what you want to create…" asks it for a starting point (a README, build files, a source layout and a first test), drafted on the hub and committed as you; the screen follows it and lists the files. The app now asks for `repo:admin` at sign-in (making repositories needs it); a phone signed in before says to sign in again. |
|
| 6 | 8 | - **Colour on icons (#25):** icons say what they are in the hub's colours, as its pages do: repositories, folders and account details in the accent, releases, projects and pending checks in purple, private and internal locks in their warning colour, notifications by kind (a broken build red, a fixed one green, a review purple), and empty screens' icons in the accent. |
|
| 7 | 9 | ||
| 8 | 10 | ## 0.1.0 (2026-10-28) |
|
| … | 26 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/DeviceFlow.kt +4 −1
| … | 9 unchanged lines |
||
| 10 | 10 | import java.io.IOException |
|
| 11 | 11 | ||
| 12 | 12 | /** The scopes the app asks for. The user may untick any of them on the web. */ |
|
| 13 | - | val AppScopes: List<String> = listOf("repo:read", "repo:write", "user:read") |
|
| 13 | + | val AppScopes: List<String> = listOf("repo:read", "repo:write", "repo:admin", "user:read") |
|
| 14 | 14 | ||
| 15 | 15 | /** Lands proposals (and more as the app grows); asked for since #17. */ |
|
| 16 | 16 | const val WriteScope = "repo:write" |
|
| 17 | 17 | ||
| 18 | + | /** Makes repositories (#27); asked for since 0.1.1. */ |
|
| 19 | + | const val AdminScope = "repo:admin" |
|
| 20 | + | ||
| 18 | 21 | /** Why a device sign-in ended without a token. */ |
|
| 19 | 22 | sealed class DeviceFlowException(message: String) : Exception(message) { |
|
| 20 | 23 | class Denied : DeviceFlowException("The sign-in was denied on the web.") |
|
| … | 89 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +42 −0
| … | 56 unchanged lines |
||
| 57 | 57 | /** Your sky's data, the dashboard backdrop's (#23); a hub before it answers 404. */ |
|
| 58 | 58 | suspend fun userSky(): UserSky |
|
| 59 | 59 | ||
| 60 | + | /** The organisations the account belongs to (oxygenhub#302). */ |
|
| 61 | + | suspend fun userOrgs(): List<UserOrg> |
|
| 62 | + | ||
| 63 | + | /** Create a repository (#27): `repo:admin`. */ |
|
| 64 | + | suspend fun createRepository(owner: String, name: String, description: String, visibility: Visibility): Repository |
|
| 65 | + | ||
| 66 | + | /** The assistants a new repository under [owner] may ask for its starting files (oxygenhub#347). */ |
|
| 67 | + | suspend fun starterAssistants(owner: String): List<StarterAssistant> |
|
| 68 | + | ||
| 69 | + | /** Ask an assistant for an empty repository's starting files; it works in the background. */ |
|
| 70 | + | suspend fun startStarter(owner: String, name: String, description: String): StarterStatus |
|
| 71 | + | ||
| 72 | + | suspend fun starterStatus(owner: String, name: String): StarterStatus |
|
| 73 | + | ||
| 74 | + | /** The newest runs of your repositories, with their jobs (#26); [before] (ms) pages back. */ |
|
| 75 | + | suspend fun userFlowRuns(before: Long? = null): List<UserFlowRun> |
|
| 76 | + | ||
| 60 | 77 | /** One repository's sky, its pages' backdrop (#23); a hub before it answers 404. */ |
|
| 61 | 78 | suspend fun repoSky(owner: String, name: String): UserSky |
|
| 62 | 79 | ||
| … | 263 unchanged lines |
||
| 326 | 343 | } |
|
| 327 | 344 | ||
| 328 | 345 | override suspend fun userSky(): UserSky = get(url("user", "sky"), UserSky.serializer()) |
|
| 346 | + | ||
| 347 | + | override suspend fun userOrgs(): List<UserOrg> = get(url("user", "orgs"), ListSerializer(UserOrg.serializer())) |
|
| 348 | + | ||
| 349 | + | override suspend fun createRepository(owner: String, name: String, description: String, visibility: Visibility): Repository { |
|
| 350 | + | val json = buildJsonObject { |
|
| 351 | + | put("owner", owner) |
|
| 352 | + | put("name", name) |
|
| 353 | + | put("description", description) |
|
| 354 | + | put("visibility", HubJson.encodeToJsonElement(Visibility.serializer(), visibility)) |
|
| 355 | + | } |
|
| 356 | + | return sendJson("POST", url("repos"), json, Repository.serializer()) |
|
| 357 | + | } |
|
| 358 | + | ||
| 359 | + | override suspend fun starterAssistants(owner: String): List<StarterAssistant> = |
|
| 360 | + | get(url("user", "starter") { addQueryParameter("owner", owner) }, StarterAssistants.serializer()).assistants |
|
| 361 | + | ||
| 362 | + | override suspend fun startStarter(owner: String, name: String, description: String): StarterStatus = |
|
| 363 | + | sendJson("POST", url("repos", owner, name, "starter"), buildJsonObject { put("description", description) }, StarterStatus.serializer()) |
|
| 364 | + | ||
| 365 | + | override suspend fun starterStatus(owner: String, name: String): StarterStatus = |
|
| 366 | + | get(url("repos", owner, name, "starter"), StarterStatus.serializer()) |
|
| 367 | + | ||
| 368 | + | override suspend fun userFlowRuns(before: Long?): List<UserFlowRun> = |
|
| 369 | + | get(url("user", "flow", "runs") { before?.let { addQueryParameter("before", it.toString()) } }, ListSerializer(JsonObject.serializer())) |
|
| 370 | + | .map { o -> UserFlowRun((o["repository"] as? JsonPrimitive)?.content.orEmpty(), HubJson.decodeFromJsonElement(FlowRun.serializer(), o)) } |
|
| 329 | 371 | ||
| 330 | 372 | override suspend fun repoSky(owner: String, name: String): UserSky = get(url("repos", owner, name, "sky"), UserSky.serializer()) |
|
| 331 | 373 | ||
| … | 128 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +31 −0
| … | 270 unchanged lines |
||
| 271 | 271 | val events: List<List<Long>> = emptyList(), |
|
| 272 | 272 | ) |
|
| 273 | 273 | ||
| 274 | + | /** `GET /api/v1/user/orgs`: an organisation the caller belongs to, and their role in it. */ |
|
| 275 | + | @Serializable |
|
| 276 | + | data class UserOrg( |
|
| 277 | + | val name: String, |
|
| 278 | + | @SerialName("display_name") val displayName: String = "", |
|
| 279 | + | val role: String = "", |
|
| 280 | + | ) |
|
| 281 | + | ||
| 282 | + | /** `GET /api/v1/user/starter?owner=X` (#27, oxygenhub#347): an assistant a new repository may ask. */ |
|
| 283 | + | @Serializable |
|
| 284 | + | data class StarterAssistant(val name: String, val model: String = "", val from: String = "") |
|
| 285 | + | ||
| 286 | + | @Serializable |
|
| 287 | + | data class StarterAssistants(val assistants: List<StarterAssistant> = emptyList()) |
|
| 288 | + | ||
| 289 | + | /** Where a repository's starter stands: `running`, `done`, `failed` or `none`. */ |
|
| 290 | + | @Serializable |
|
| 291 | + | data class StarterStatus( |
|
| 292 | + | val status: String = "none", |
|
| 293 | + | val assistant: String = "", |
|
| 294 | + | val change: String = "", |
|
| 295 | + | val files: List<String> = emptyList(), |
|
| 296 | + | val error: String = "", |
|
| 297 | + | ) |
|
| 298 | + | ||
| 299 | + | /** `GET /api/v1/user/flow/runs` (#26): a run of one of your repositories. */ |
|
| 300 | + | data class UserFlowRun(val repository: String, val run: FlowRun) { |
|
| 301 | + | val owner: String get() = repository.substringBefore('/') |
|
| 302 | + | val name: String get() = repository.substringAfter('/') |
|
| 303 | + | } |
|
| 304 | + | ||
| 274 | 305 | /** An organisation (`GET /orgs/{name}`). */ |
|
| 275 | 306 | @Serializable |
|
| 276 | 307 | data class Organisation( |
|
| … | 236 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/auth/Session.kt +3 −0
| … | 14 unchanged lines |
||
| 15 | 15 | */ |
|
| 16 | 16 | val canWrite: Boolean get() = scopes.isEmpty() || io.oxygenhub.android.api.WriteScope in scopes |
|
| 17 | 17 | ||
| 18 | + | /** Whether the token may make repositories (#27); a sign-in before 0.1.1 was not asked for it. */ |
|
| 19 | + | val canCreate: Boolean get() = scopes.isEmpty() || io.oxygenhub.android.api.AdminScope in scopes |
|
| 20 | + | ||
| 18 | 21 | // The token never reaches a log through toString. |
|
| 19 | 22 | override fun toString(): String = "Session(hub=$hub, username=$username, scopes=$scopes, token=<redacted>)" |
|
| 20 | 23 | } |
|
| … | 22 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +27 −1
| … | 8 unchanged lines |
||
| 9 | 9 | import androidx.compose.material.icons.filled.Book |
|
| 10 | 10 | import androidx.compose.material.icons.filled.Dashboard |
|
| 11 | 11 | import androidx.compose.material.icons.filled.Inbox |
|
| 12 | + | import androidx.compose.material.icons.filled.RocketLaunch |
|
| 12 | 13 | import androidx.compose.material.icons.filled.Search |
|
| 13 | 14 | import androidx.compose.material.icons.filled.ViewKanban |
|
| 14 | 15 | import androidx.compose.material.icons.outlined.AccountCircle |
|
| 15 | 16 | import androidx.compose.material.icons.outlined.Book |
|
| 16 | 17 | import androidx.compose.material.icons.outlined.Dashboard |
|
| 17 | 18 | import androidx.compose.material.icons.outlined.Inbox |
|
| 19 | + | import androidx.compose.material.icons.outlined.RocketLaunch |
|
| 18 | 20 | import androidx.compose.material.icons.outlined.Search |
|
| 19 | 21 | import androidx.compose.material.icons.outlined.ViewKanban |
|
| 20 | 22 | import androidx.compose.material3.Badge |
|
| … | 37 unchanged lines |
||
| 58 | 60 | import io.oxygenhub.android.ui.common.rememberAvatar |
|
| 59 | 61 | import io.oxygenhub.android.ui.dashboard.DashboardScreen |
|
| 60 | 62 | import io.oxygenhub.android.ui.dashboard.DashboardViewModel |
|
| 63 | + | import io.oxygenhub.android.ui.flow.FlowMonitorScreen |
|
| 64 | + | import io.oxygenhub.android.ui.flow.FlowMonitorViewModel |
|
| 61 | 65 | import io.oxygenhub.android.ui.flow.JobLogScreen |
|
| 62 | 66 | import io.oxygenhub.android.ui.flow.JobLogViewModel |
|
| 63 | 67 | import io.oxygenhub.android.ui.flow.RunScreen |
|
| … | 20 unchanged lines |
||
| 84 | 88 | import io.oxygenhub.android.ui.releases.ReleasesViewModel |
|
| 85 | 89 | import io.oxygenhub.android.ui.releases.SearchScreen |
|
| 86 | 90 | import io.oxygenhub.android.ui.releases.SearchViewModel |
|
| 91 | + | import io.oxygenhub.android.ui.repos.NewRepositoryScreen |
|
| 92 | + | import io.oxygenhub.android.ui.repos.NewRepositoryViewModel |
|
| 87 | 93 | import io.oxygenhub.android.ui.repos.RepositoriesScreen |
|
| 88 | 94 | import io.oxygenhub.android.ui.repos.RepositoriesViewModel |
|
| 89 | 95 | import io.oxygenhub.android.ui.repos.RepositoryScreen |
|
| … | 10 unchanged lines |
||
| 100 | 106 | ||
| 101 | 107 | @Serializable data object RepositoriesRoute |
|
| 102 | 108 | ||
| 109 | + | @Serializable data object NewRepositoryRoute |
|
| 110 | + | ||
| 111 | + | @Serializable data object FlowRoute |
|
| 112 | + | ||
| 103 | 113 | @Serializable data object InboxRoute |
|
| 104 | 114 | ||
| 105 | 115 | @Serializable data object ProjectsRoute |
|
| … | 49 unchanged lines |
||
| 155 | 165 | ) { |
|
| 156 | 166 | Dashboard("Dashboard", Icons.Outlined.Dashboard, Icons.Filled.Dashboard, DashboardRoute, DashboardRoute::class), |
|
| 157 | 167 | Repositories("Repositories", Icons.Outlined.Book, Icons.Filled.Book, RepositoriesRoute, RepositoriesRoute::class), |
|
| 168 | + | Flow("Flow", Icons.Outlined.RocketLaunch, Icons.Filled.RocketLaunch, FlowRoute, FlowRoute::class), |
|
| 158 | 169 | Projects("Projects", Icons.Outlined.ViewKanban, Icons.Filled.ViewKanban, ProjectsRoute, ProjectsRoute::class, wide = true), |
|
| 159 | 170 | Search("Search", Icons.Outlined.Search, Icons.Filled.Search, SearchRoute, SearchRoute::class, wide = true), |
|
| 160 | 171 | Inbox("Inbox", Icons.Outlined.Inbox, Icons.Filled.Inbox, InboxRoute, InboxRoute::class), |
|
| … | 83 unchanged lines |
||
| 244 | 255 | } |
|
| 245 | 256 | } |
|
| 246 | 257 | composable<RepositoriesRoute> { |
|
| 247 | - | RepositoriesScreen(rememberViewModel { RepositoriesViewModel(api, session.username) }, selectedRepository) { slug, onBack -> |
|
| 258 | + | RepositoriesScreen( |
|
| 259 | + | rememberViewModel { RepositoriesViewModel(api, session.username) }, |
|
| 260 | + | selectedRepository, |
|
| 261 | + | onNew = { nav.navigate(NewRepositoryRoute) }, |
|
| 262 | + | ) { slug, onBack -> |
|
| 248 | 263 | val (owner, name) = slug.split('/', limit = 2) |
|
| 249 | 264 | RepositoryScreen( |
|
| 250 | 265 | rememberViewModel(key = "code:$slug") { CodeViewModel(api, owner, name) }, |
|
| … | 75 unchanged lines |
||
| 326 | 341 | composable<IssueRoute> { e -> |
|
| 327 | 342 | val r = e.toRoute<IssueRoute>() |
|
| 328 | 343 | IssueScreen(rememberViewModel { IssueViewModel(api, r.owner, r.name, r.number) }, canWrite = session.canWrite, onBack = { nav.popBackStack() }, me = session.username) |
|
| 344 | + | } |
|
| 345 | + | composable<FlowRoute> { |
|
| 346 | + | FlowMonitorScreen(rememberViewModel { FlowMonitorViewModel(api) }) { owner, name, number -> nav.navigate(RunRoute(owner, name, number)) } |
|
| 347 | + | } |
|
| 348 | + | composable<NewRepositoryRoute> { |
|
| 349 | + | NewRepositoryScreen( |
|
| 350 | + | rememberViewModel { NewRepositoryViewModel(api, session.username) }, |
|
| 351 | + | canCreate = session.canCreate, |
|
| 352 | + | onBack = { nav.popBackStack() }, |
|
| 353 | + | onOpen = { r -> nav.navigate(IssuesRoute(r.owner, r.name)) { popUpTo<NewRepositoryRoute> { inclusive = true } } }, |
|
| 354 | + | ) |
|
| 329 | 355 | } |
|
| 330 | 356 | composable<NewIssueRoute> { e -> |
|
| 331 | 357 | val r = e.toRoute<NewIssueRoute>() |
|
| … | 69 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/repos/RepositoriesScreen.kt +16 −7
| 1 | 1 | package io.oxygenhub.android.ui.repos |
|
| 2 | 2 | ||
| 3 | + | import androidx.compose.foundation.gestures.awaitEachGesture |
|
| 4 | + | import androidx.compose.foundation.gestures.awaitFirstDown |
|
| 3 | 5 | import androidx.compose.foundation.layout.Arrangement |
|
| 4 | 6 | import androidx.compose.foundation.layout.Column |
|
| 5 | 7 | import androidx.compose.foundation.layout.Row |
|
| … | 3 unchanged lines |
||
| 9 | 11 | import androidx.compose.foundation.lazy.LazyColumn |
|
| 10 | 12 | import androidx.compose.foundation.lazy.items |
|
| 11 | 13 | import androidx.compose.material.icons.Icons |
|
| 14 | + | import androidx.compose.material.icons.outlined.Add |
|
| 12 | 15 | import androidx.compose.material.icons.outlined.Book |
|
| 13 | 16 | import androidx.compose.material.icons.outlined.Search |
|
| 14 | 17 | import androidx.compose.material3.FilterChip |
|
| 15 | 18 | import androidx.compose.material3.HorizontalDivider |
|
| 16 | 19 | import androidx.compose.material3.Icon |
|
| 20 | + | import androidx.compose.material3.IconButton |
|
| 17 | 21 | import androidx.compose.material3.OutlinedTextField |
|
| 18 | 22 | import androidx.compose.material3.Text |
|
| 19 | 23 | import androidx.compose.material3.TextButton |
|
| … | 8 unchanged lines |
||
| 28 | 32 | import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator |
|
| 29 | 33 | import androidx.compose.runtime.Composable |
|
| 30 | 34 | import androidx.compose.runtime.getValue |
|
| 31 | - | import androidx.compose.runtime.rememberCoroutineScope |
|
| 32 | - | import androidx.compose.ui.Modifier |
|
| 33 | - | import androidx.compose.foundation.gestures.awaitEachGesture |
|
| 34 | - | import androidx.compose.foundation.gestures.awaitFirstDown |
|
| 35 | 35 | import androidx.compose.runtime.mutableStateOf |
|
| 36 | 36 | import androidx.compose.runtime.remember |
|
| 37 | + | import androidx.compose.runtime.rememberCoroutineScope |
|
| 37 | 38 | import androidx.compose.runtime.setValue |
|
| 39 | + | import androidx.compose.ui.Modifier |
|
| 38 | 40 | import androidx.compose.ui.composed |
|
| 39 | 41 | import androidx.compose.ui.focus.focusProperties |
|
| 40 | 42 | import androidx.compose.ui.input.InputMode |
|
| … | 20 unchanged lines |
||
| 61 | 63 | fun RepositoriesScreen( |
|
| 62 | 64 | vm: RepositoriesViewModel, |
|
| 63 | 65 | initialSelection: String? = null, |
|
| 66 | + | /** New repository (#27), when this phone may make one. */ |
|
| 67 | + | onNew: (() -> Unit)? = null, |
|
| 64 | 68 | detail: @Composable (slug: String, onBack: (() -> Unit)?) -> Unit, |
|
| 65 | 69 | ) { |
|
| 66 | 70 | val info = currentWindowAdaptiveInfo() |
|
| … | 17 unchanged lines |
||
| 84 | 88 | AnimatedPane { |
|
| 85 | 89 | RepositoryList(vm, selected = selected, onOpen = { repo -> |
|
| 86 | 90 | scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, repo.slug) } |
|
| 87 | - | }) |
|
| 91 | + | }, onNew = onNew) |
|
| 88 | 92 | } |
|
| 89 | 93 | }, |
|
| 90 | 94 | detailPane = { |
|
| … | 28 unchanged lines |
||
| 119 | 123 | } |
|
| 120 | 124 | ||
| 121 | 125 | @Composable |
|
| 122 | - | private fun RepositoryList(vm: RepositoriesViewModel, selected: String?, onOpen: (Repository) -> Unit) { |
|
| 126 | + | private fun RepositoryList(vm: RepositoriesViewModel, selected: String?, onOpen: (Repository) -> Unit, onNew: (() -> Unit)? = null) { |
|
| 123 | 127 | val state by vm.state.collectAsStateWithLifecycle() |
|
| 124 | 128 | val query by vm.query.collectAsStateWithLifecycle() |
|
| 125 | - | AppScreen(title = "Repositories") { |
|
| 129 | + | AppScreen( |
|
| 130 | + | title = "Repositories", |
|
| 131 | + | actions = { |
|
| 132 | + | onNew?.let { IconButton(onClick = it) { Icon(Icons.Outlined.Add, contentDescription = "New repository") } } |
|
| 133 | + | }, |
|
| 134 | + | ) { |
|
| 126 | 135 | Column(Modifier.fillMaxSize()) { |
|
| 127 | 136 | OutlinedTextField( |
|
| 128 | 137 | value = query.text, |
|
| … | 35 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +20 −0
| … | 168 unchanged lines |
||
| 169 | 169 | io.oxygenhub.android.api.UserSky(now = 10 * 86_400_000L, repos = listOf("andymccall/oxygen"), events = listOf(listOf(0L, 0L, 9 * 86_400_000L, 3L))) |
|
| 170 | 170 | } |
|
| 171 | 171 | override suspend fun userSky() = userSky.invoke().also { calls += "user sky" } |
|
| 172 | + | var userOrgs: () -> List<io.oxygenhub.android.api.UserOrg> = { listOf(io.oxygenhub.android.api.UserOrg("doomsdayone", role = "owner")) } |
|
| 173 | + | override suspend fun userOrgs() = userOrgs.invoke().also { calls += "user orgs" } |
|
| 174 | + | var createRepository: (String, String, String, io.oxygenhub.android.api.Visibility) -> Repository = { o, n, d, v -> |
|
| 175 | + | Repository(owner = o, name = n, description = d, visibility = v) |
|
| 176 | + | } |
|
| 177 | + | override suspend fun createRepository(owner: String, name: String, description: String, visibility: io.oxygenhub.android.api.Visibility) = |
|
| 178 | + | createRepository.invoke(owner, name, description, visibility).also { calls += "create $owner/$name" } |
|
| 179 | + | var starterAssistants: (String) -> List<io.oxygenhub.android.api.StarterAssistant> = { emptyList() } |
|
| 180 | + | override suspend fun starterAssistants(owner: String) = starterAssistants.invoke(owner).also { calls += "starter assistants $owner" } |
|
| 181 | + | var startStarter: (String) -> io.oxygenhub.android.api.StarterStatus = { io.oxygenhub.android.api.StarterStatus("running") } |
|
| 182 | + | override suspend fun startStarter(owner: String, name: String, description: String) = |
|
| 183 | + | startStarter.invoke(description).also { calls += "start starter $owner/$name" } |
|
| 184 | + | /** Each call answers the next status in the list (the last one repeats). */ |
|
| 185 | + | var starterStatuses: MutableList<io.oxygenhub.android.api.StarterStatus> = mutableListOf() |
|
| 186 | + | override suspend fun starterStatus(owner: String, name: String): io.oxygenhub.android.api.StarterStatus { |
|
| 187 | + | calls += "starter status $owner/$name" |
|
| 188 | + | return if (starterStatuses.size > 1) starterStatuses.removeAt(0) else starterStatuses.firstOrNull() ?: io.oxygenhub.android.api.StarterStatus() |
|
| 189 | + | } |
|
| 190 | + | var userFlowRuns: () -> List<io.oxygenhub.android.api.UserFlowRun> = { emptyList() } |
|
| 191 | + | override suspend fun userFlowRuns(before: Long?) = userFlowRuns.invoke().also { calls += "user flow runs" } |
|
| 172 | 192 | override suspend fun repoSky(owner: String, name: String) = |
|
| 173 | 193 | userSky.invoke().copy(repos = listOf("$owner/$name")).also { calls += "sky $owner/$name" } |
|
| 174 | 194 | override suspend fun userProposals(landable: Boolean) = |
|
| … | 27 unchanged lines |