OXYGENHUB

Releases and search on the phone (refs #3) dfbc3e972872

A repository's Releases tab shows each release's notes rendered, who
signed it and its files with their sizes and downloads; a file
downloads through the browser. Search (from the dashboard) finds issues
and proposals across every repository the account can read, each
opening on its own screen.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key c71fa557fc8b
When
· 2026-10-25 23:24 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
dfbc3e97287291965f9b128b446d06648fdd358c300006042b1109a3541e9f3d
Depends on
144cbf7bf2b5 1c42a70fcba3 31e4f3755627 4aee5293461d 57c0824300db 5ae4af48ddd2 98dbd1220929 d1f635be9442 e2df27a81e5d e833eb0a9e7f
Depended on by
29a375612f92 aa627197ec18 821035db5a1c d98e5a147428 840c75813f86 6e2af63ae616 6fcaca2bd500
In bookmarks
main
In tags
v0.1.0 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
27 atoms, 3 tree operations, 8 files

Browse the tree at this change

added app/src/main/java/io/oxygenhub/android/ui/releases/Releases.kt +179 −0
1 +
package io.oxygenhub.android.ui.releases
2 +
3 +
import androidx.compose.foundation.clickable
4 +
import androidx.compose.foundation.layout.Arrangement
5 +
import androidx.compose.foundation.layout.Column
6 +
import androidx.compose.foundation.layout.fillMaxSize
7 +
import androidx.compose.foundation.layout.fillMaxWidth
8 +
import androidx.compose.foundation.layout.padding
9 +
import androidx.compose.foundation.lazy.LazyColumn
10 +
import androidx.compose.foundation.lazy.items
11 +
import androidx.compose.foundation.text.KeyboardActions
12 +
import androidx.compose.foundation.text.KeyboardOptions
13 +
import androidx.compose.material.icons.Icons
14 +
import androidx.compose.material.icons.outlined.Download
15 +
import androidx.compose.material.icons.outlined.NewReleases
16 +
import androidx.compose.material.icons.outlined.Search
17 +
import androidx.compose.material3.ElevatedCard
18 +
import androidx.compose.material3.HorizontalDivider
19 +
import androidx.compose.material3.Icon
20 +
import androidx.compose.material3.ListItem
21 +
import androidx.compose.material3.MaterialTheme
22 +
import androidx.compose.material3.OutlinedTextField
23 +
import androidx.compose.material3.Text
24 +
import androidx.compose.runtime.Composable
25 +
import androidx.compose.runtime.getValue
26 +
import androidx.compose.runtime.mutableStateOf
27 +
import androidx.compose.runtime.saveable.rememberSaveable
28 +
import androidx.compose.runtime.setValue
29 +
import androidx.compose.ui.Modifier
30 +
import androidx.compose.ui.platform.LocalUriHandler
31 +
import androidx.compose.ui.text.input.ImeAction
32 +
import androidx.compose.ui.unit.dp
33 +
import androidx.lifecycle.ViewModel
34 +
import androidx.lifecycle.compose.collectAsStateWithLifecycle
35 +
import androidx.lifecycle.viewModelScope
36 +
import com.mikepenz.markdown.m3.Markdown
37 +
import io.oxygenhub.android.api.HubApi
38 +
import io.oxygenhub.android.api.Release
39 +
import io.oxygenhub.android.api.SearchHit
40 +
import io.oxygenhub.android.ui.code.bytes
41 +
import io.oxygenhub.android.ui.common.AppScreen
42 +
import io.oxygenhub.android.ui.common.EmptyState
43 +
import io.oxygenhub.android.ui.common.Load
44 +
import io.oxygenhub.android.ui.common.LoadContent
45 +
import io.oxygenhub.android.ui.common.load
46 +
import io.oxygenhub.android.ui.common.relativeTime
47 +
import kotlinx.coroutines.CancellationException
48 +
import kotlinx.coroutines.Job
49 +
import kotlinx.coroutines.flow.MutableStateFlow
50 +
import kotlinx.coroutines.flow.StateFlow
51 +
import kotlinx.coroutines.flow.asStateFlow
52 +
import kotlinx.coroutines.launch
53 +
54 +
/** A repository's releases, newest first. */
55 +
class ReleasesViewModel(private val api: HubApi, val owner: String, val name: String) : ViewModel() {
56 +
    private val _state = MutableStateFlow<Load<List<Release>>>(Load.Loading)
57 +
    val state: StateFlow<Load<List<Release>>> = _state.asStateFlow()
58 +
    private var job: Job? = null
59 +
60 +
    init {
61 +
        refresh()
62 +
    }
63 +
64 +
    fun refresh() {
65 +
        job = load(_state, job) { api.releases(owner, name).sortedByDescending { it.createdMs } }
66 +
    }
67 +
}
68 +
69 +
/** Issues and proposals found across the account's repositories. */
70 +
class SearchViewModel(private val api: HubApi) : ViewModel() {
71 +
    private val _state = MutableStateFlow<Load<List<SearchHit>>?>(null)
72 +
73 +
    /** Null before the first search. */
74 +
    val state: StateFlow<Load<List<SearchHit>>?> = _state.asStateFlow()
75 +
    private var job: Job? = null
76 +
77 +
    fun search(query: String) {
78 +
        val q = query.trim()
79 +
        if (q.isEmpty()) return
80 +
        job?.cancel()
81 +
        _state.value = Load.Loading
82 +
        job = viewModelScope.launch {
83 +
            _state.value = try {
84 +
                Load.Ready(api.search(q))
85 +
            } catch (e: CancellationException) {
86 +
                throw e
87 +
            } catch (e: Exception) {
88 +
                Load.failed(e)
89 +
            }
90 +
        }
91 +
    }
92 +
}
93 +
94 +
/** The releases tab: each release's notes rendered and its files to download. */
95 +
@Composable
96 +
fun ReleaseList(vm: ReleasesViewModel) {
97 +
    val state by vm.state.collectAsStateWithLifecycle()
98 +
    val uri = LocalUriHandler.current
99 +
    LoadContent(state, onRefresh = vm::refresh) { releases ->
100 +
        if (releases.isEmpty()) {
101 +
            EmptyState(Icons.Outlined.NewReleases, "No releases", "Tagged releases of ${vm.owner}/${vm.name} show here.")
102 +
        } else {
103 +
            LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
104 +
                items(releases, key = { it.tag }) { r ->
105 +
                    ElevatedCard(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) {
106 +
                        Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
107 +
                            Text(r.title.ifEmpty { r.tag }, style = MaterialTheme.typography.titleMedium)
108 +
                            Text(
109 +
                                listOfNotNull(
110 +
                                    r.tag,
111 +
                                    "latest".takeIf { r.latest },
112 +
                                    "pre-release".takeIf { r.prerelease },
113 +
                                    r.signer?.let { "signed by $it" },
114 +
                                    relativeTime(r.createdMs).takeIf { it.isNotEmpty() },
115 +
                                ).joinToString(" · "),
116 +
                                style = MaterialTheme.typography.labelMedium,
117 +
                                color = MaterialTheme.colorScheme.onSurfaceVariant,
118 +
                            )
119 +
                            if (r.notes.isNotBlank()) Markdown(r.notes)
120 +
                            r.assets.forEach { a ->
121 +
                                ListItem(
122 +
                                    modifier = Modifier.clickable { if (a.url.isNotBlank()) uri.openUri(a.url) },
123 +
                                    leadingContent = { Icon(Icons.Outlined.Download, contentDescription = "Download") },
124 +
                                    headlineContent = { Text(a.name) },
125 +
                                    supportingContent = { Text("${bytes(a.size)} · ${a.downloads} downloads") },
126 +
                                )
127 +
                            }
128 +
                        }
129 +
                    }
130 +
                }
131 +
            }
132 +
        }
133 +
    }
134 +
}
135 +
136 +
/** Search issues and proposals; a result opens on its own screen. */
137 +
@Composable
138 +
fun SearchScreen(vm: SearchViewModel, onBack: () -> Unit, onOpen: (SearchHit) -> Unit) {
139 +
    val state by vm.state.collectAsStateWithLifecycle()
140 +
    var query by rememberSaveable { mutableStateOf("") }
141 +
    AppScreen(title = "Search", onBack = onBack) {
142 +
        Column(Modifier.fillMaxSize()) {
143 +
            OutlinedTextField(
144 +
                query,
145 +
                { query = it },
146 +
                label = { Text("Issues and proposals") },
147 +
                singleLine = true,
148 +
                leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) },
149 +
                keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
150 +
                keyboardActions = KeyboardActions(onSearch = { vm.search(query) }),
151 +
                modifier = Modifier.fillMaxWidth().padding(12.dp),
152 +
            )
153 +
            when (val s = state) {
154 +
                null -> EmptyState(Icons.Outlined.Search, "Search", "Words from titles, bodies and comments, in every repository you can read.")
155 +
                else -> LoadContent(s, onRefresh = { vm.search(query) }) { hits ->
156 +
                    if (hits.isEmpty()) {
157 +
                        EmptyState(Icons.Outlined.Search, "Nothing found", "No issue or proposal matches \"$query\".")
158 +
                    } else {
159 +
                        LazyColumn {
160 +
                            items(hits, key = { "${it.repository}#${it.number}" }) { h ->
161 +
                                ListItem(
162 +
                                    modifier = Modifier.clickable { onOpen(h) },
163 +
                                    overlineContent = { Text("${h.repository} · ${h.kind} #${h.number}") },
164 +
                                    headlineContent = { Text(h.title) },
165 +
                                    supportingContent = if (h.snippet.isNotBlank()) {
166 +
                                        { Text(h.snippet, maxLines = 2) }
167 +
                                    } else {
168 +
                                        null
169 +
                                    },
170 +
                                )
171 +
                                HorizontalDivider()
172 +
                            }
173 +
                        }
174 +
                    }
175 +
                }
176 +
            }
177 +
        }
178 +
    }
179 +
}
added app/src/test/java/io/oxygenhub/android/ui/ReleasesAndSearchTest.kt +44 −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.HubException
6 +
import io.oxygenhub.android.api.Release
7 +
import io.oxygenhub.android.api.SearchHit
8 +
import io.oxygenhub.android.ui.common.Load
9 +
import io.oxygenhub.android.ui.releases.ReleasesViewModel
10 +
import io.oxygenhub.android.ui.releases.SearchViewModel
11 +
import org.junit.Assert.assertEquals
12 +
import org.junit.Assert.assertNull
13 +
import org.junit.Assert.assertTrue
14 +
import org.junit.Rule
15 +
import org.junit.Test
16 +
17 +
class ReleasesAndSearchTest {
18 +
    @get:Rule val main = MainDispatcherRule()
19 +
20 +
    private val api = FakeHubApi()
21 +
22 +
    @Test
23 +
    fun releasesNewestFirst() {
24 +
        api.releaseList = listOf(Release("v0.9.3", createdMs = 1), Release("v0.9.4", createdMs = 2))
25 +
        val tags = (ReleasesViewModel(api, "andymccall", "oxygenhub").state.value as Load.Ready).value.map { it.tag }
26 +
        assertEquals(listOf("v0.9.4", "v0.9.3"), tags)
27 +
    }
28 +
29 +
    @Test
30 +
    fun searchFindsIssuesAndProposalsAndSaysWhenItFails() {
31 +
        api.hits = { q -> if (q == "board") listOf(SearchHit("issue", "andymccall/oxygenhub", 293, "Boards that keep growing")) else throw HubException.Http(500, "down") }
32 +
        val vm = SearchViewModel(api)
33 +
        assertNull("nothing searched yet", vm.state.value)
34 +
        vm.search("   ")
35 +
        assertNull("a blank search is no search", vm.state.value)
36 +
        vm.search(" board ")
37 +
        val hits = (vm.state.value as Load.Ready).value
38 +
        assertEquals(listOf(293L), hits.map { it.number })
39 +
        assertEquals("andymccall" to "oxygenhub", hits[0].owner to hits[0].name)
40 +
        assertTrue("search board" in api.calls)
41 +
        vm.search("fails")
42 +
        assertTrue(vm.state.value is Load.Failed)
43 +
    }
44 +
}
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +12 −0
…
68 unchanged lines
69 69
70 70
    suspend fun issue(owner: String, name: String, number: Long): Issue
71 71
72 +
    /** A repository's releases, newest first. */
73 +
    suspend fun releases(owner: String, name: String): List<Release>
74 +
75 +
    /** Issues and proposals matching [query], across the repositories the caller can read. */
76 +
    suspend fun search(query: String): List<SearchHit>
77 +
72 78
    /** A repository's runs, newest first. */
73 79
    suspend fun runs(owner: String, name: String): List<FlowRun>
74 80
…
79 unchanged lines
154 160
        get(url("notifications") { addQueryParameter("limit", limit.coerceIn(1, 200).toString()) }, Inbox.serializer())
155 161
156 162
    override suspend fun runnerToken(): RunnerToken = send("POST", url("user", "runner-token"), RunnerToken.serializer())
163 +
164 +
    override suspend fun releases(owner: String, name: String): List<Release> =
165 +
        get(url("repos", owner, name, "releases"), ListSerializer(Release.serializer()))
166 +
167 +
    override suspend fun search(query: String): List<SearchHit> =
168 +
        get(url("search") { addQueryParameter("q", query) }, ListSerializer(SearchHit.serializer()))
157 169
158 170
    override suspend fun runs(owner: String, name: String): List<FlowRun> =
159 171
        get(url("repos", owner, name, "flow", "runs"), ListSerializer(FlowRun.serializer()))
…
216 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +32 −0
…
76 unchanged lines
77 77
    val isOpen: Boolean get() = state == "open"
78 78
}
79 79
80 +
/** A release of a repository: its tag, notes and files. */
81 +
@Serializable
82 +
data class Release(
83 +
    val tag: String,
84 +
    val title: String = "",
85 +
    val notes: String = "",
86 +
    val prerelease: Boolean = false,
87 +
    val latest: Boolean = false,
88 +
    val author: String = "",
89 +
    val signer: String? = null,
90 +
    val assets: List<ReleaseAsset> = emptyList(),
91 +
    @SerialName("created_ms") val createdMs: Long = 0,
92 +
    val url: String = "",
93 +
)
94 +
95 +
@Serializable
96 +
data class ReleaseAsset(val name: String, val size: Long = 0, val hash: String = "", val downloads: Long = 0, val url: String = "")
97 +
98 +
/** An issue or proposal search found (`GET /search?q=`). */
99 +
@Serializable
100 +
data class SearchHit(
101 +
    /** `issue` or `proposal`. */
102 +
    val kind: String = "issue",
103 +
    val repository: String = "",
104 +
    val number: Long = 0,
105 +
    val title: String = "",
106 +
    val snippet: String = "",
107 +
) {
108 +
    val owner: String get() = repository.substringBefore('/')
109 +
    val name: String get() = repository.substringAfter('/')
110 +
}
111 +
80 112
/** A Flow run of a repository's pipeline (#5). */
81 113
@Serializable
82 114
data class FlowRun(
…
330 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/dashboard/DashboardScreen.kt +18 −7
1 1
package io.oxygenhub.android.ui.dashboard
2 2
3 3
import androidx.compose.foundation.clickable
4 -
import androidx.compose.material.icons.automirrored.outlined.CallMerge
5 -
import androidx.compose.material.icons.outlined.EditNote
6 -
import androidx.compose.ui.text.style.TextOverflow
7 -
import io.oxygenhub.android.api.ProposalBrief
8 -
import io.oxygenhub.android.ui.common.relativeTime
9 -
import io.oxygenhub.android.ui.theme.LocalStatusColors
10 4
import androidx.compose.foundation.layout.Arrangement
11 5
import androidx.compose.foundation.layout.BoxWithConstraints
12 6
import androidx.compose.foundation.layout.Column
…
5 unchanged lines
18 12
import androidx.compose.foundation.rememberScrollState
19 13
import androidx.compose.foundation.verticalScroll
20 14
import androidx.compose.material.icons.Icons
15 +
import androidx.compose.material.icons.automirrored.outlined.CallMerge
16 +
import androidx.compose.material.icons.outlined.EditNote
21 17
import androidx.compose.material.icons.outlined.Inbox
22 18
import androidx.compose.material.icons.outlined.MarkEmailUnread
19 +
import androidx.compose.material.icons.outlined.Search
23 20
import androidx.compose.material3.Card
24 21
import androidx.compose.material3.HorizontalDivider
25 22
import androidx.compose.material3.Icon
23 +
import androidx.compose.material3.IconButton
26 24
import androidx.compose.material3.ListItem
27 25
import androidx.compose.material3.ListItemDefaults
28 26
import androidx.compose.material3.MaterialTheme
…
5 unchanged lines
34 32
import androidx.compose.ui.Modifier
35 33
import androidx.compose.ui.graphics.Color
36 34
import androidx.compose.ui.text.font.FontWeight
35 +
import androidx.compose.ui.text.style.TextOverflow
37 36
import androidx.compose.ui.unit.dp
38 37
import androidx.lifecycle.compose.collectAsStateWithLifecycle
39 38
import io.oxygenhub.android.api.Inbox
39 +
import io.oxygenhub.android.api.ProposalBrief
40 40
import io.oxygenhub.android.api.Repository
41 41
import io.oxygenhub.android.ui.common.AppScreen
42 42
import io.oxygenhub.android.ui.common.AvatarImage
43 43
import io.oxygenhub.android.ui.common.Load
44 44
import io.oxygenhub.android.ui.common.LoadContent
45 +
import io.oxygenhub.android.ui.common.relativeTime
45 46
import io.oxygenhub.android.ui.repos.RepositoryRow
47 +
import io.oxygenhub.android.ui.theme.LocalStatusColors
46 48
47 49
@Composable
48 50
fun DashboardScreen(
…
5 unchanged lines
54 56
    onAllRepositories: () -> Unit,
55 57
    onInbox: () -> Unit,
56 58
    onOpenProposal: (ProposalBrief) -> Unit = {},
59 +
    onSearch: (() -> Unit)? = null,
57 60
) {
58 61
    val state by vm.state.collectAsStateWithLifecycle()
59 -
    AppScreen(title = "Dashboard", subtitle = hub.substringAfter("://")) {
62 +
    AppScreen(
63 +
        title = "Dashboard",
64 +
        subtitle = hub.substringAfter("://"),
65 +
        actions = {
66 +
            if (onSearch != null) {
67 +
                IconButton(onClick = onSearch) { Icon(Icons.Outlined.Search, contentDescription = "Search issues and proposals") }
68 +
            }
69 +
        },
70 +
    ) {
60 71
        LoadContent(state, onRefresh = { vm.refresh(); onRefreshInbox() }) { d ->
61 72
            DashboardContent(d, inbox, onOpenRepository, onAllRepositories, onInbox, onOpenProposal)
62 73
        }
…
138 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +13 −0
…
66 unchanged lines
67 67
import io.oxygenhub.android.ui.proposals.ProposalScreen
68 68
import io.oxygenhub.android.ui.proposals.ProposalViewModel
69 69
import io.oxygenhub.android.ui.proposals.ProposalsViewModel
70 +
import io.oxygenhub.android.ui.releases.ReleasesViewModel
71 +
import io.oxygenhub.android.ui.releases.SearchScreen
72 +
import io.oxygenhub.android.ui.releases.SearchViewModel
70 73
import io.oxygenhub.android.ui.repos.RepositoriesScreen
71 74
import io.oxygenhub.android.ui.repos.RepositoriesViewModel
72 75
import io.oxygenhub.android.ui.repos.RepositoryScreen
…
14 unchanged lines
87 90
88 91
@Serializable data object ProjectsRoute
89 92
93 +
@Serializable data object SearchRoute
94 +
90 95
/** A project board. */
91 96
@Serializable data class BoardRoute(val owner: String, val number: Long)
92 97
…
90 unchanged lines
183 188
                    onAllRepositories = { nav.goTo(TopLevel.Repositories) },
184 189
                    onInbox = { nav.goTo(TopLevel.Inbox) },
185 190
                    onOpenProposal = { nav.navigate(ProposalRoute(it.owner, it.name, it.number)) },
191 +
                    onSearch = { nav.navigate(SearchRoute) },
186 192
                )
193 +
            }
194 +
            composable<SearchRoute> {
195 +
                SearchScreen(rememberViewModel { SearchViewModel(api) }, onBack = { nav.popBackStack() }) { h ->
196 +
                    nav.navigate(if (h.kind == "proposal") ProposalRoute(h.owner, h.name, h.number) else IssueRoute(h.owner, h.name, h.number))
197 +
                }
187 198
            }
188 199
            composable<RepositoriesRoute> {
189 200
                RepositoriesScreen(rememberViewModel { RepositoriesViewModel(api, session.username) }, selectedRepository) { slug, onBack ->
…
3 unchanged lines
193 204
                        rememberViewModel(key = "issues:$slug") { IssuesViewModel(api, owner, name) },
194 205
                        rememberViewModel(key = "proposals:$slug") { ProposalsViewModel(api, owner, name) },
195 206
                        rememberViewModel(key = "runs:$slug") { RunsViewModel(api, owner, name) },
207 +
                        rememberViewModel(key = "releases:$slug") { ReleasesViewModel(api, owner, name) },
196 208
                        onBack,
197 209
                        onOpenProposal = { nav.navigate(ProposalRoute(owner, name, it.number)) },
198 210
                        onOpenFile = { ref, path -> nav.navigate(FileRoute(owner, name, ref, path)) },
…
11 unchanged lines
210 222
                    rememberViewModel { IssuesViewModel(api, r.owner, r.name) },
211 223
                    rememberViewModel { ProposalsViewModel(api, r.owner, r.name) },
212 224
                    rememberViewModel { RunsViewModel(api, r.owner, r.name) },
225 +
                    rememberViewModel { ReleasesViewModel(api, r.owner, r.name) },
213 226
                    onBack = { nav.popBackStack() },
214 227
                    onOpenProposal = { nav.navigate(ProposalRoute(r.owner, r.name, it.number)) },
215 228
                    onOpenFile = { ref, path -> nav.navigate(FileRoute(r.owner, r.name, ref, path)) },
…
102 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/repos/RepositoryScreen.kt +5 −1
…
16 unchanged lines
17 17
import io.oxygenhub.android.ui.code.CodeViewModel
18 18
import io.oxygenhub.android.ui.flow.RunList
19 19
import io.oxygenhub.android.ui.flow.RunsViewModel
20 +
import io.oxygenhub.android.ui.releases.ReleaseList
21 +
import io.oxygenhub.android.ui.releases.ReleasesViewModel
20 22
import io.oxygenhub.android.ui.common.AppScreen
21 23
import io.oxygenhub.android.ui.issues.IssueList
22 24
import io.oxygenhub.android.ui.issues.IssuesViewModel
…
1 unchanged lines
24 26
import io.oxygenhub.android.ui.proposals.ProposalsViewModel
25 27
26 28
/** A repository's tabs: its code, issues, proposals and Flow runs (#3, #5). */
27 -
enum class RepositoryTab(val label: String) { Code("Code"), Issues("Issues"), Proposals("Proposals"), Runs("Runs") }
29 +
enum class RepositoryTab(val label: String) { Code("Code"), Issues("Issues"), Proposals("Proposals"), Runs("Runs"), Releases("Releases") }
28 30
29 31
@Composable
30 32
fun RepositoryScreen(
…
1 unchanged lines
32 34
    issues: IssuesViewModel,
33 35
    proposals: ProposalsViewModel,
34 36
    runs: RunsViewModel,
37 +
    releases: ReleasesViewModel,
35 38
    onBack: (() -> Unit)?,
36 39
    onOpenProposal: (Proposal) -> Unit,
37 40
    onOpenFile: (ref: String, path: String) -> Unit,
…
16 unchanged lines
54 57
                RepositoryTab.Issues -> IssueList(issues, onOpenIssue, onNewIssue)
55 58
                RepositoryTab.Proposals -> ProposalList(proposals, onOpenProposal)
56 59
                RepositoryTab.Runs -> RunList(runs) { onOpenRun(it.number) }
60 +
                RepositoryTab.Releases -> ReleaseList(releases)
57 61
            }
58 62
        }
59 63
    }
…
1 unchanged lines
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +4 −0
…
58 unchanged lines
59 59
    }
60 60
    var runnerToken: () -> io.oxygenhub.android.api.RunnerToken = { io.oxygenhub.android.api.RunnerToken("flowreg_test", "andymccall") }
61 61
    override suspend fun runnerToken() = runnerToken.invoke().also { calls += "runner token" }
62 +
    var releaseList: List<io.oxygenhub.android.api.Release> = emptyList()
63 +
    override suspend fun releases(owner: String, name: String) = releaseList.also { calls += "releases" }
64 +
    var hits: (String) -> List<io.oxygenhub.android.api.SearchHit> = { emptyList() }
65 +
    override suspend fun search(query: String) = hits(query).also { calls += "search $query" }
62 66
    var runList: List<io.oxygenhub.android.api.FlowRun> = emptyList()
63 67
    override suspend fun runs(owner: String, name: String) = runList.also { calls += "runs" }
64 68
    val runStore = mutableMapOf<Long, io.oxygenhub.android.api.FlowRun>()
…
98 unchanged lines