OXYGENHUB

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