Project boards on the phone: every board you can see, columns to swipe, cards that open and move e833eb0a9e7f
Projects is a destination of its own: the account's boards and those of the owners of its repositories (its organisations), open ones first. A board shows its columns as tabs to swipe between; a card opens its issue or proposal, and a long press moves it to another column, at once, put back if the hub refuses.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 5645f450c39d
- When
- · 2026-10-25 22:44 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
e833eb0a9e7fc3d3cde33da13fa3ae89924b81622a5f00c1056a5d9c1a69d797- Depends on
- 144cbf7bf2b5 31e4f3755627 4aee5293461d 57c0824300db 5ae4af48ddd2 98dbd1220929
- Depended on by
- 840c75813f86 dce8ebd2482a ca672a3ada1f 40ab4df121e0 62429abe5869 6fcaca2bd500 dfbc3e972872 1c42a70fcba3
- 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
- 13 atoms, 4 tree operations, 7 files
Browse the tree at this change
added app/src/main/java/io/oxygenhub/android/ui/projects/ProjectsScreen.kt +165 −0
| 1 | + | package io.oxygenhub.android.ui.projects |
|
| 2 | + | ||
| 3 | + | import androidx.compose.foundation.ExperimentalFoundationApi |
|
| 4 | + | import androidx.compose.foundation.combinedClickable |
|
| 5 | + | import androidx.compose.foundation.clickable |
|
| 6 | + | import androidx.compose.foundation.layout.Arrangement |
|
| 7 | + | import androidx.compose.foundation.layout.Box |
|
| 8 | + | import androidx.compose.foundation.layout.Column |
|
| 9 | + | import androidx.compose.foundation.layout.ExperimentalLayoutApi |
|
| 10 | + | import androidx.compose.foundation.layout.FlowRow |
|
| 11 | + | import androidx.compose.foundation.layout.fillMaxSize |
|
| 12 | + | import androidx.compose.foundation.layout.fillMaxWidth |
|
| 13 | + | import androidx.compose.foundation.layout.padding |
|
| 14 | + | import androidx.compose.foundation.lazy.LazyColumn |
|
| 15 | + | import androidx.compose.foundation.lazy.items |
|
| 16 | + | import androidx.compose.foundation.pager.HorizontalPager |
|
| 17 | + | import androidx.compose.foundation.pager.rememberPagerState |
|
| 18 | + | import androidx.compose.material.icons.Icons |
|
| 19 | + | import androidx.compose.material.icons.outlined.ViewKanban |
|
| 20 | + | import androidx.compose.material3.DropdownMenu |
|
| 21 | + | import androidx.compose.material3.DropdownMenuItem |
|
| 22 | + | import androidx.compose.material3.ElevatedCard |
|
| 23 | + | import androidx.compose.material3.HorizontalDivider |
|
| 24 | + | import androidx.compose.material3.ListItem |
|
| 25 | + | import androidx.compose.material3.MaterialTheme |
|
| 26 | + | import androidx.compose.material3.PrimaryScrollableTabRow |
|
| 27 | + | import androidx.compose.material3.SuggestionChip |
|
| 28 | + | import androidx.compose.material3.Tab |
|
| 29 | + | import androidx.compose.material3.Text |
|
| 30 | + | import androidx.compose.runtime.Composable |
|
| 31 | + | import androidx.compose.runtime.getValue |
|
| 32 | + | import androidx.compose.runtime.mutableStateOf |
|
| 33 | + | import androidx.compose.runtime.remember |
|
| 34 | + | import androidx.compose.runtime.rememberCoroutineScope |
|
| 35 | + | import androidx.compose.runtime.setValue |
|
| 36 | + | import androidx.compose.ui.Modifier |
|
| 37 | + | import androidx.compose.ui.unit.dp |
|
| 38 | + | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 39 | + | import io.oxygenhub.android.api.Project |
|
| 40 | + | import io.oxygenhub.android.api.ProjectCard |
|
| 41 | + | import io.oxygenhub.android.ui.common.AppScreen |
|
| 42 | + | import io.oxygenhub.android.ui.common.EmptyState |
|
| 43 | + | import io.oxygenhub.android.ui.common.LoadContent |
|
| 44 | + | import io.oxygenhub.android.ui.common.relativeTime |
|
| 45 | + | import io.oxygenhub.android.ui.theme.LocalStatusColors |
|
| 46 | + | import kotlinx.coroutines.launch |
|
| 47 | + | ||
| 48 | + | /** The boards the account can see, each opening its columns. */ |
|
| 49 | + | @Composable |
|
| 50 | + | fun ProjectsScreen(vm: ProjectsViewModel, onOpen: (Project) -> Unit) { |
|
| 51 | + | val state by vm.state.collectAsStateWithLifecycle() |
|
| 52 | + | AppScreen(title = "Projects") { |
|
| 53 | + | LoadContent(state, onRefresh = vm::refresh) { projects -> |
|
| 54 | + | if (projects.isEmpty()) { |
|
| 55 | + | EmptyState(Icons.Outlined.ViewKanban, "No boards yet", "Project boards you and your organisations keep show here.") |
|
| 56 | + | } else { |
|
| 57 | + | LazyColumn { |
|
| 58 | + | items(projects, key = { "${it.owner}/${it.number}" }) { p -> |
|
| 59 | + | ListItem( |
|
| 60 | + | modifier = Modifier.clickable { onOpen(p) }, |
|
| 61 | + | headlineContent = { Text(p.title) }, |
|
| 62 | + | supportingContent = { |
|
| 63 | + | Text( |
|
| 64 | + | listOfNotNull( |
|
| 65 | + | "${p.owner} · #${p.number}", |
|
| 66 | + | "closed".takeIf { p.closed }, |
|
| 67 | + | "private".takeIf { !p.public }, |
|
| 68 | + | relativeTime(p.updatedMs).takeIf { it.isNotEmpty() }, |
|
| 69 | + | ).joinToString(" · "), |
|
| 70 | + | ) |
|
| 71 | + | }, |
|
| 72 | + | ) |
|
| 73 | + | HorizontalDivider() |
|
| 74 | + | } |
|
| 75 | + | } |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | /** |
|
| 82 | + | * A board: its columns as tabs to swipe between, each column's cards; a |
|
| 83 | + | * card opens its issue or proposal, and a long press moves it. |
|
| 84 | + | */ |
|
| 85 | + | @Composable |
|
| 86 | + | fun BoardScreen(vm: BoardViewModel, canWrite: Boolean, onBack: () -> Unit, onOpenCard: (ProjectCard) -> Unit) { |
|
| 87 | + | val state by vm.state.collectAsStateWithLifecycle() |
|
| 88 | + | val error by vm.error.collectAsStateWithLifecycle() |
|
| 89 | + | AppScreen( |
|
| 90 | + | title = (state as? io.oxygenhub.android.ui.common.Load.Ready)?.value?.project?.title ?: "Board", |
|
| 91 | + | subtitle = "${vm.owner} · #${vm.number}", |
|
| 92 | + | onBack = onBack, |
|
| 93 | + | ) { |
|
| 94 | + | LoadContent(state, onRefresh = vm::refresh) { board -> |
|
| 95 | + | val pages = rememberPagerState { board.columns.size } |
|
| 96 | + | val scope = rememberCoroutineScope() |
|
| 97 | + | Column(Modifier.fillMaxSize()) { |
|
| 98 | + | if (board.columns.isNotEmpty()) { |
|
| 99 | + | PrimaryScrollableTabRow(selectedTabIndex = pages.currentPage.coerceAtMost(board.columns.size - 1)) { |
|
| 100 | + | board.columns.forEachIndexed { i, c -> |
|
| 101 | + | Tab(selected = pages.currentPage == i, onClick = { scope.launch { pages.animateScrollToPage(i) } }, text = { Text("${c.name} ${c.cards.size}") }) |
|
| 102 | + | } |
|
| 103 | + | } |
|
| 104 | + | } |
|
| 105 | + | error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(12.dp)) } |
|
| 106 | + | HorizontalPager(pages, Modifier.fillMaxSize()) { page -> |
|
| 107 | + | val column = board.columns[page] |
|
| 108 | + | if (column.cards.isEmpty()) { |
|
| 109 | + | EmptyState(Icons.Outlined.ViewKanban, "Nothing in ${column.name}", "Cards moved here show up here.") |
|
| 110 | + | } else { |
|
| 111 | + | LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 112 | + | items(column.cards, key = { it.id }) { card -> |
|
| 113 | + | CardView(card, columns = board.columns.map { it.name }.filter { it != column.name }, canWrite, onOpen = { onOpenCard(card) }, onMove = { vm.move(card.id, it) }) |
|
| 114 | + | } |
|
| 115 | + | } |
|
| 116 | + | } |
|
| 117 | + | } |
|
| 118 | + | } |
|
| 119 | + | } |
|
| 120 | + | } |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | @OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) |
|
| 124 | + | @Composable |
|
| 125 | + | private fun CardView(card: ProjectCard, columns: List<String>, canWrite: Boolean, onOpen: () -> Unit, onMove: (String) -> Unit) { |
|
| 126 | + | var menu by remember { mutableStateOf(false) } |
|
| 127 | + | val status = LocalStatusColors.current |
|
| 128 | + | Box { |
|
| 129 | + | ElevatedCard( |
|
| 130 | + | Modifier.fillMaxWidth().padding(horizontal = 12.dp).combinedClickable( |
|
| 131 | + | onClick = { if (card.kind != "note") onOpen() }, |
|
| 132 | + | onLongClick = { if (canWrite && columns.isNotEmpty()) menu = true }, |
|
| 133 | + | ), |
|
| 134 | + | ) { |
|
| 135 | + | Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { |
|
| 136 | + | if (card.reference.isNotEmpty()) { |
|
| 137 | + | Text( |
|
| 138 | + | "${card.reference} · ${card.state}", |
|
| 139 | + | style = MaterialTheme.typography.labelSmall, |
|
| 140 | + | color = when (card.state) { |
|
| 141 | + | "open" -> status.ok |
|
| 142 | + | "landed", "closed" -> status.warn |
|
| 143 | + | else -> MaterialTheme.colorScheme.onSurfaceVariant |
|
| 144 | + | }, |
|
| 145 | + | ) |
|
| 146 | + | } |
|
| 147 | + | Text(card.title, style = MaterialTheme.typography.bodyMedium) |
|
| 148 | + | if (card.labels.isNotEmpty() || card.assignees.isNotEmpty()) { |
|
| 149 | + | FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { |
|
| 150 | + | card.labels.forEach { SuggestionChip(onClick = {}, label = { Text(it, style = MaterialTheme.typography.labelSmall) }) } |
|
| 151 | + | card.assignees.forEach { Text("@$it", style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(4.dp)) } |
|
| 152 | + | } |
|
| 153 | + | } |
|
| 154 | + | } |
|
| 155 | + | } |
|
| 156 | + | DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { |
|
| 157 | + | columns.forEach { c -> |
|
| 158 | + | DropdownMenuItem(text = { Text("Move to $c") }, onClick = { |
|
| 159 | + | menu = false |
|
| 160 | + | onMove(c) |
|
| 161 | + | }) |
|
| 162 | + | } |
|
| 163 | + | } |
|
| 164 | + | } |
|
| 165 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/projects/ProjectsViewModel.kt +96 −0
| 1 | + | package io.oxygenhub.android.ui.projects |
|
| 2 | + | ||
| 3 | + | import androidx.lifecycle.ViewModel |
|
| 4 | + | import androidx.lifecycle.viewModelScope |
|
| 5 | + | import io.oxygenhub.android.api.Board |
|
| 6 | + | import io.oxygenhub.android.api.HubApi |
|
| 7 | + | import io.oxygenhub.android.api.Project |
|
| 8 | + | import io.oxygenhub.android.api.userMessage |
|
| 9 | + | import io.oxygenhub.android.ui.common.Load |
|
| 10 | + | import io.oxygenhub.android.ui.common.load |
|
| 11 | + | import kotlinx.coroutines.CancellationException |
|
| 12 | + | import kotlinx.coroutines.Job |
|
| 13 | + | import kotlinx.coroutines.async |
|
| 14 | + | import kotlinx.coroutines.awaitAll |
|
| 15 | + | import kotlinx.coroutines.coroutineScope |
|
| 16 | + | import kotlinx.coroutines.flow.MutableStateFlow |
|
| 17 | + | import kotlinx.coroutines.flow.StateFlow |
|
| 18 | + | import kotlinx.coroutines.flow.asStateFlow |
|
| 19 | + | import kotlinx.coroutines.launch |
|
| 20 | + | ||
| 21 | + | /** |
|
| 22 | + | * The boards the account can see: its own and those of the owners of its |
|
| 23 | + | * repositories (its organisations), open ones first, newest first. |
|
| 24 | + | */ |
|
| 25 | + | class ProjectsViewModel(private val api: HubApi, private val username: String) : ViewModel() { |
|
| 26 | + | private val _state = MutableStateFlow<Load<List<Project>>>(Load.Loading) |
|
| 27 | + | val state: StateFlow<Load<List<Project>>> = _state.asStateFlow() |
|
| 28 | + | private var job: Job? = null |
|
| 29 | + | ||
| 30 | + | init { |
|
| 31 | + | refresh() |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | fun refresh() { |
|
| 35 | + | job = load(_state, job) { |
|
| 36 | + | val owners = (listOf(username) + api.repositories().map { it.owner }).distinct() |
|
| 37 | + | val found = coroutineScope { |
|
| 38 | + | // An owner whose boards will not load is left out, not the list. |
|
| 39 | + | owners.map { o -> async { runCatching { api.projects(o) }.getOrDefault(emptyList()) } }.awaitAll().flatten() |
|
| 40 | + | } |
|
| 41 | + | order(found) |
|
| 42 | + | } |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | companion object { |
|
| 46 | + | fun order(projects: List<Project>): List<Project> = |
|
| 47 | + | projects.distinctBy { it.owner to it.number }.sortedWith(compareBy<Project> { it.closed }.thenByDescending { it.updatedMs }) |
|
| 48 | + | } |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | /** One board: its columns and cards; a card moves to another column. */ |
|
| 52 | + | class BoardViewModel(private val api: HubApi, val owner: String, val number: Long) : ViewModel() { |
|
| 53 | + | private val _state = MutableStateFlow<Load<Board>>(Load.Loading) |
|
| 54 | + | val state: StateFlow<Load<Board>> = _state.asStateFlow() |
|
| 55 | + | ||
| 56 | + | private val _error = MutableStateFlow<String?>(null) |
|
| 57 | + | val error: StateFlow<String?> = _error.asStateFlow() |
|
| 58 | + | ||
| 59 | + | private var job: Job? = null |
|
| 60 | + | ||
| 61 | + | init { |
|
| 62 | + | refresh() |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | fun refresh() { |
|
| 66 | + | job = load(_state, job) { api.board(owner, number) } |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | /** Move [card] to [column]: shown at once, put back if the hub refuses. */ |
|
| 70 | + | fun move(card: Long, column: String) { |
|
| 71 | + | val before = (_state.value as? Load.Ready)?.value ?: return |
|
| 72 | + | val moving = before.columns.flatMap { it.cards }.firstOrNull { it.id == card } ?: return |
|
| 73 | + | if (before.columns.firstOrNull { c -> c.cards.any { it.id == card } }?.name == column) return |
|
| 74 | + | _state.value = Load.Ready( |
|
| 75 | + | before.copy(columns = before.columns.map { c -> |
|
| 76 | + | val rest = c.cards.filter { it.id != card } |
|
| 77 | + | if (c.name == column) c.copy(cards = rest + moving) else c.copy(cards = rest) |
|
| 78 | + | }), |
|
| 79 | + | ) |
|
| 80 | + | _error.value = null |
|
| 81 | + | viewModelScope.launch { |
|
| 82 | + | try { |
|
| 83 | + | api.moveCard(owner, number, card, column) |
|
| 84 | + | } catch (e: CancellationException) { |
|
| 85 | + | throw e |
|
| 86 | + | } catch (e: Exception) { |
|
| 87 | + | _state.value = Load.Ready(before) |
|
| 88 | + | _error.value = e.userMessage() |
|
| 89 | + | } |
|
| 90 | + | } |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | fun dismissError() { |
|
| 94 | + | _error.value = null |
|
| 95 | + | } |
|
| 96 | + | } |
added app/src/test/java/io/oxygenhub/android/ui/ProjectsViewModelTest.kt +82 −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.Board |
|
| 6 | + | import io.oxygenhub.android.api.HubApi |
|
| 7 | + | import io.oxygenhub.android.api.HubException |
|
| 8 | + | import io.oxygenhub.android.api.Project |
|
| 9 | + | import io.oxygenhub.android.api.ProjectCard |
|
| 10 | + | import io.oxygenhub.android.api.ProjectColumn |
|
| 11 | + | import io.oxygenhub.android.api.Repository |
|
| 12 | + | import io.oxygenhub.android.ui.common.Load |
|
| 13 | + | import io.oxygenhub.android.ui.projects.BoardViewModel |
|
| 14 | + | import io.oxygenhub.android.ui.projects.ProjectsViewModel |
|
| 15 | + | import org.junit.Assert.assertEquals |
|
| 16 | + | import org.junit.Assert.assertNull |
|
| 17 | + | import org.junit.Assert.assertTrue |
|
| 18 | + | import org.junit.Rule |
|
| 19 | + | import org.junit.Test |
|
| 20 | + | ||
| 21 | + | class ProjectsViewModelTest { |
|
| 22 | + | @get:Rule val main = MainDispatcherRule() |
|
| 23 | + | ||
| 24 | + | private val api = FakeHubApi() |
|
| 25 | + | ||
| 26 | + | @Test |
|
| 27 | + | fun boardsOfTheAccountAndItsRepositoriesOwnersOpenFirst() { |
|
| 28 | + | api.repositories = { listOf(Repository("andymccall", "oxygen"), Repository("doomsdayone", "tetris")) } |
|
| 29 | + | api.projectsOf = { owner -> |
|
| 30 | + | when (owner) { |
|
| 31 | + | "andymccall" -> listOf(Project("andymccall", 3, "OxygenHub", updatedMs = 10), Project("andymccall", 1, "Old", closed = true, updatedMs = 99)) |
|
| 32 | + | "doomsdayone" -> listOf(Project("doomsdayone", 4, "Super Mario Land 2", updatedMs = 20)) |
|
| 33 | + | else -> throw HubException.Http(404, "no such owner") |
|
| 34 | + | } |
|
| 35 | + | } |
|
| 36 | + | val vm = ProjectsViewModel(api, "andymccall") |
|
| 37 | + | val projects = (vm.state.value as Load.Ready).value |
|
| 38 | + | assertEquals(listOf("Super Mario Land 2", "OxygenHub", "Old"), projects.map { it.title }) |
|
| 39 | + | assertTrue("projects doomsdayone" in api.calls) |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | @Test |
|
| 43 | + | fun anOwnerWhoseBoardsWillNotLoadIsLeftOut() { |
|
| 44 | + | api.repositories = { listOf(Repository("gone", "x")) } |
|
| 45 | + | api.projectsOf = { owner -> if (owner == "gone") throw HubException.Http(500, "down") else listOf(Project(owner, 1, "Mine")) } |
|
| 46 | + | val projects = (ProjectsViewModel(api, "andymccall").state.value as Load.Ready).value |
|
| 47 | + | assertEquals(listOf("Mine"), projects.map { it.title }) |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | private val board = Board( |
|
| 51 | + | Project("andymccall", 3, "OxygenHub"), |
|
| 52 | + | listOf( |
|
| 53 | + | ProjectColumn(1, "Doing", listOf(ProjectCard(10, "issue", "andymccall/oxygenhub#315", "Form lines up", "open"))), |
|
| 54 | + | ProjectColumn(2, "Done", emptyList()), |
|
| 55 | + | ), |
|
| 56 | + | ) |
|
| 57 | + | ||
| 58 | + | @Test |
|
| 59 | + | fun aCardMovesToAnotherColumn() { |
|
| 60 | + | api.boards[3] = board |
|
| 61 | + | val vm = BoardViewModel(api, "andymccall", 3) |
|
| 62 | + | vm.move(10, "Done") |
|
| 63 | + | val b = (vm.state.value as Load.Ready).value |
|
| 64 | + | assertEquals(listOf(0, 1), b.columns.map { it.cards.size }) |
|
| 65 | + | assertTrue("move 10 Done" in api.calls) |
|
| 66 | + | assertEquals(Triple("andymccall", "oxygenhub", 315L), b.columns[1].cards[0].target) |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | @Test |
|
| 70 | + | fun aMoveTheHubRefusesIsPutBack() { |
|
| 71 | + | api.boards[3] = board |
|
| 72 | + | val refusing = object : HubApi by api { |
|
| 73 | + | override suspend fun moveCard(owner: String, number: Long, card: Long, column: String) = throw HubException.Http(403, "needs write access") |
|
| 74 | + | } |
|
| 75 | + | val vm = BoardViewModel(refusing, "andymccall", 3) |
|
| 76 | + | vm.move(10, "Done") |
|
| 77 | + | assertEquals(listOf(1, 0), (vm.state.value as Load.Ready).value.columns.map { it.cards.size }) |
|
| 78 | + | assertTrue(vm.error.value!!.contains("write access")) |
|
| 79 | + | vm.dismissError() |
|
| 80 | + | assertNull(vm.error.value) |
|
| 81 | + | } |
|
| 82 | + | } |
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +17 −0
| … | 68 unchanged lines |
||
| 69 | 69 | ||
| 70 | 70 | suspend fun issue(owner: String, name: String, number: Long): Issue |
|
| 71 | 71 | ||
| 72 | + | /** [owner]'s project boards the caller can see. */ |
|
| 73 | + | suspend fun projects(owner: String): List<Project> |
|
| 74 | + | ||
| 75 | + | suspend fun board(owner: String, number: Long): Board |
|
| 76 | + | ||
| 77 | + | /** Move a card to the column named [column]. */ |
|
| 78 | + | suspend fun moveCard(owner: String, number: Long, card: Long, column: String) |
|
| 79 | + | ||
| 72 | 80 | suspend fun issueComments(owner: String, name: String, number: Long): List<Comment> |
|
| 73 | 81 | ||
| 74 | 82 | suspend fun comment(owner: String, name: String, number: Long, body: String): Comment |
|
| … | 55 unchanged lines |
||
| 130 | 138 | get(url("notifications") { addQueryParameter("limit", limit.coerceIn(1, 200).toString()) }, Inbox.serializer()) |
|
| 131 | 139 | ||
| 132 | 140 | override suspend fun runnerToken(): RunnerToken = send("POST", url("user", "runner-token"), RunnerToken.serializer()) |
|
| 141 | + | ||
| 142 | + | override suspend fun projects(owner: String): List<Project> = get(url("projects", owner), ListSerializer(Project.serializer())) |
|
| 143 | + | ||
| 144 | + | override suspend fun board(owner: String, number: Long): Board = get(url("projects", owner, number.toString()), Board.serializer()) |
|
| 145 | + | ||
| 146 | + | override suspend fun moveCard(owner: String, number: Long, card: Long, column: String) { |
|
| 147 | + | val json = buildJsonObject { put("column", column) } |
|
| 148 | + | call(request(url("projects", owner, number.toString(), "cards", card.toString())).method("PATCH", json.toString().toRequestBody(JsonMedia)).build(), true) |
|
| 149 | + | } |
|
| 133 | 150 | ||
| 134 | 151 | override suspend fun issue(owner: String, name: String, number: Long): Issue = |
|
| 135 | 152 | get(url("repos", owner, name, "issues", number.toString()), Issue.serializer()) |
|
| … | 183 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +42 −0
| … | 76 unchanged lines |
||
| 77 | 77 | val isOpen: Boolean get() = state == "open" |
|
| 78 | 78 | } |
|
| 79 | 79 | ||
| 80 | + | /** A project board's summary (`GET /projects/{owner}`). */ |
|
| 81 | + | @Serializable |
|
| 82 | + | data class Project( |
|
| 83 | + | val owner: String, |
|
| 84 | + | val number: Long, |
|
| 85 | + | val title: String = "", |
|
| 86 | + | val description: String = "", |
|
| 87 | + | val public: Boolean = true, |
|
| 88 | + | val closed: Boolean = false, |
|
| 89 | + | @SerialName("updated_ms") val updatedMs: Long = 0, |
|
| 90 | + | val url: String = "", |
|
| 91 | + | ) |
|
| 92 | + | ||
| 93 | + | /** A card on a board: an issue, a proposal or a note. */ |
|
| 94 | + | @Serializable |
|
| 95 | + | data class ProjectCard( |
|
| 96 | + | val id: Long, |
|
| 97 | + | /** `issue`, `proposal` or `note`. */ |
|
| 98 | + | val kind: String = "note", |
|
| 99 | + | /** `owner/name#N`; empty for a note. */ |
|
| 100 | + | val reference: String = "", |
|
| 101 | + | val title: String = "", |
|
| 102 | + | val state: String = "", |
|
| 103 | + | val labels: List<String> = emptyList(), |
|
| 104 | + | val assignees: List<String> = emptyList(), |
|
| 105 | + | ) { |
|
| 106 | + | /** The repository and number a card refers to, or null for a note. */ |
|
| 107 | + | val target: Triple<String, String, Long>? get() { |
|
| 108 | + | val slug = reference.substringBefore('#', "") |
|
| 109 | + | val n = reference.substringAfter('#', "").toLongOrNull() ?: return null |
|
| 110 | + | val (owner, name) = slug.split('/').takeIf { it.size == 2 } ?: return null |
|
| 111 | + | return Triple(owner, name, n) |
|
| 112 | + | } |
|
| 113 | + | } |
|
| 114 | + | ||
| 115 | + | @Serializable |
|
| 116 | + | data class ProjectColumn(val id: Long, val name: String, val cards: List<ProjectCard> = emptyList()) |
|
| 117 | + | ||
| 118 | + | /** A board, its columns in order (`GET /projects/{owner}/{n}`). */ |
|
| 119 | + | @Serializable |
|
| 120 | + | data class Board(val project: Project, val columns: List<ProjectColumn> = emptyList()) |
|
| 121 | + | ||
| 80 | 122 | /** A comment on an issue or proposal. */ |
|
| 81 | 123 | @Serializable |
|
| 82 | 124 | data class Comment( |
|
| … | 244 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +28 −0
| … | 4 unchanged lines |
||
| 5 | 5 | import androidx.compose.material.icons.filled.Book |
|
| 6 | 6 | import androidx.compose.material.icons.filled.Dashboard |
|
| 7 | 7 | import androidx.compose.material.icons.filled.Inbox |
|
| 8 | + | import androidx.compose.material.icons.filled.ViewKanban |
|
| 8 | 9 | import androidx.compose.material.icons.outlined.AccountCircle |
|
| 9 | 10 | import androidx.compose.material.icons.outlined.Book |
|
| 10 | 11 | import androidx.compose.material.icons.outlined.Dashboard |
|
| 11 | 12 | import androidx.compose.material.icons.outlined.Inbox |
|
| 13 | + | import androidx.compose.material.icons.outlined.ViewKanban |
|
| 12 | 14 | import androidx.compose.material3.Badge |
|
| 13 | 15 | import androidx.compose.material3.BadgedBox |
|
| 14 | 16 | import androidx.compose.material3.Icon |
|
| … | 38 unchanged lines |
||
| 53 | 55 | import io.oxygenhub.android.ui.issues.IssuesViewModel |
|
| 54 | 56 | import io.oxygenhub.android.ui.issues.NewIssueScreen |
|
| 55 | 57 | import io.oxygenhub.android.ui.issues.NewIssueViewModel |
|
| 58 | + | import io.oxygenhub.android.ui.projects.BoardScreen |
|
| 59 | + | import io.oxygenhub.android.ui.projects.BoardViewModel |
|
| 60 | + | import io.oxygenhub.android.ui.projects.ProjectsScreen |
|
| 61 | + | import io.oxygenhub.android.ui.projects.ProjectsViewModel |
|
| 56 | 62 | import io.oxygenhub.android.ui.proposals.ProposalScreen |
|
| 57 | 63 | import io.oxygenhub.android.ui.proposals.ProposalViewModel |
|
| 58 | 64 | import io.oxygenhub.android.ui.proposals.ProposalsViewModel |
|
| … | 15 unchanged lines |
||
| 74 | 80 | ||
| 75 | 81 | @Serializable data object InboxRoute |
|
| 76 | 82 | ||
| 83 | + | @Serializable data object ProjectsRoute |
|
| 84 | + | ||
| 85 | + | /** A project board. */ |
|
| 86 | + | @Serializable data class BoardRoute(val owner: String, val number: Long) |
|
| 87 | + | ||
| 77 | 88 | @Serializable data object AccountRoute |
|
| 78 | 89 | ||
| 79 | 90 | /** A repository's issues, opened from outside the repositories list (the dashboard). */ |
|
| … | 17 unchanged lines |
||
| 97 | 108 | enum class TopLevel(val label: String, val icon: ImageVector, val selectedIcon: ImageVector, val route: Any, val routeClass: KClass<*>) { |
|
| 98 | 109 | Dashboard("Dashboard", Icons.Outlined.Dashboard, Icons.Filled.Dashboard, DashboardRoute, DashboardRoute::class), |
|
| 99 | 110 | Repositories("Repositories", Icons.Outlined.Book, Icons.Filled.Book, RepositoriesRoute, RepositoriesRoute::class), |
|
| 111 | + | Projects("Projects", Icons.Outlined.ViewKanban, Icons.Filled.ViewKanban, ProjectsRoute, ProjectsRoute::class), |
|
| 100 | 112 | Inbox("Inbox", Icons.Outlined.Inbox, Icons.Filled.Inbox, InboxRoute, InboxRoute::class), |
|
| 101 | 113 | Account("Account", Icons.Outlined.AccountCircle, Icons.Filled.AccountCircle, AccountRoute, AccountRoute::class), |
|
| 102 | 114 | } |
|
| … | 88 unchanged lines |
||
| 191 | 203 | onOpenHistory = { ref -> nav.navigate(HistoryRoute(r.owner, r.name, ref)) }, |
|
| 192 | 204 | onOpenIssue = { nav.navigate(IssueRoute(r.owner, r.name, it)) }, |
|
| 193 | 205 | onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(r.owner, r.name)) } } else null, |
|
| 206 | + | ) |
|
| 207 | + | } |
|
| 208 | + | composable<ProjectsRoute> { |
|
| 209 | + | ProjectsScreen(rememberViewModel { ProjectsViewModel(api, session.username) }) { p -> nav.navigate(BoardRoute(p.owner, p.number)) } |
|
| 210 | + | } |
|
| 211 | + | composable<BoardRoute> { e -> |
|
| 212 | + | val r = e.toRoute<BoardRoute>() |
|
| 213 | + | BoardScreen( |
|
| 214 | + | rememberViewModel { BoardViewModel(api, r.owner, r.number) }, |
|
| 215 | + | canWrite = session.canWrite, |
|
| 216 | + | onBack = { nav.popBackStack() }, |
|
| 217 | + | onOpenCard = { card -> |
|
| 218 | + | card.target?.let { (owner, name, n) -> |
|
| 219 | + | nav.navigate(if (card.kind == "proposal") ProposalRoute(owner, name, n) else IssueRoute(owner, name, n)) |
|
| 220 | + | } |
|
| 221 | + | }, |
|
| 194 | 222 | ) |
|
| 195 | 223 | } |
|
| 196 | 224 | composable<IssueRoute> { e -> |
|
| … | 66 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +13 −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 projectsOf: (String) -> List<io.oxygenhub.android.api.Project> = { emptyList() } |
|
| 63 | + | override suspend fun projects(owner: String) = projectsOf(owner).also { calls += "projects $owner" } |
|
| 64 | + | var boards = mutableMapOf<Long, io.oxygenhub.android.api.Board>() |
|
| 65 | + | override suspend fun board(owner: String, number: Long) = boards.getValue(number).also { calls += "board $owner/$number" } |
|
| 66 | + | override suspend fun moveCard(owner: String, number: Long, card: Long, column: String) { |
|
| 67 | + | calls += "move $card $column" |
|
| 68 | + | val b = boards.getValue(number) |
|
| 69 | + | val moving = b.columns.flatMap { it.cards }.first { it.id == card } |
|
| 70 | + | boards[number] = b.copy(columns = b.columns.map { c -> |
|
| 71 | + | val rest = c.cards.filter { it.id != card } |
|
| 72 | + | if (c.name == column) c.copy(cards = rest + moving) else c.copy(cards = rest) |
|
| 73 | + | }) |
|
| 74 | + | } |
|
| 62 | 75 | val issueStore = mutableMapOf<Long, io.oxygenhub.android.api.Issue>() |
|
| 63 | 76 | val commentStore = mutableMapOf<Long, MutableList<io.oxygenhub.android.api.Comment>>() |
|
| 64 | 77 | var repoLabels = listOf(io.oxygenhub.android.api.Label("bug", "#c62d47"), io.oxygenhub.android.api.Label("ui", "#0969da")) |
|
| … | 68 unchanged lines |