OXYGENHUB

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