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 |