OXYGENHUB

Issues on the phone: read, comment, close and reopen, labels, and new issues (refs #4) 31e4f3755627

An issue opens on its own screen rather than the web page: its title,
state, labels, assignees and milestone, the body and comments rendered
as Markdown; a comment box, Close issue or Reopen, and a label picker
that saves the whole set, when the session may write. New issue above
the list opens one. What the hub refuses is said under the thread.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 80598755ff42
When
· 2026-10-25 22:41 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
31e4f37556277c9523f9d989414ea80df21e470444f4166953b3f15599c449fa
Depends on
144cbf7bf2b5 4aee5293461d 5ae4af48ddd2 98dbd1220929 e2df27a81e5d
Depended on by
821035db5a1c 840c75813f86 dce8ebd2482a 2a15621eca66 62429abe5869 dfbc3e972872 1c42a70fcba3 e833eb0a9e7f
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
28 atoms, 3 tree operations, 9 files

Browse the tree at this change

added app/src/main/java/io/oxygenhub/android/ui/issues/IssueScreen.kt +189 −0
1 +
package io.oxygenhub.android.ui.issues
2 +
3 +
import androidx.compose.foundation.layout.Arrangement
4 +
import androidx.compose.foundation.layout.Column
5 +
import androidx.compose.foundation.layout.ExperimentalLayoutApi
6 +
import androidx.compose.foundation.layout.FlowRow
7 +
import androidx.compose.foundation.layout.Row
8 +
import androidx.compose.foundation.layout.fillMaxWidth
9 +
import androidx.compose.foundation.layout.imePadding
10 +
import androidx.compose.foundation.layout.padding
11 +
import androidx.compose.foundation.lazy.LazyColumn
12 +
import androidx.compose.foundation.lazy.items
13 +
import androidx.compose.material.icons.Icons
14 +
import androidx.compose.material.icons.automirrored.outlined.Label
15 +
import androidx.compose.material3.AlertDialog
16 +
import androidx.compose.material3.Button
17 +
import androidx.compose.material3.Checkbox
18 +
import androidx.compose.material3.ElevatedCard
19 +
import androidx.compose.material3.HorizontalDivider
20 +
import androidx.compose.material3.Icon
21 +
import androidx.compose.material3.IconButton
22 +
import androidx.compose.material3.MaterialTheme
23 +
import androidx.compose.material3.OutlinedButton
24 +
import androidx.compose.material3.OutlinedTextField
25 +
import androidx.compose.material3.Text
26 +
import androidx.compose.material3.TextButton
27 +
import androidx.compose.runtime.Composable
28 +
import androidx.compose.runtime.LaunchedEffect
29 +
import androidx.compose.runtime.getValue
30 +
import androidx.compose.runtime.mutableStateListOf
31 +
import androidx.compose.runtime.mutableStateOf
32 +
import androidx.compose.runtime.remember
33 +
import androidx.compose.runtime.saveable.rememberSaveable
34 +
import androidx.compose.runtime.setValue
35 +
import androidx.compose.ui.Alignment
36 +
import androidx.compose.ui.Modifier
37 +
import androidx.compose.ui.unit.dp
38 +
import androidx.lifecycle.compose.collectAsStateWithLifecycle
39 +
import com.mikepenz.markdown.m3.Markdown
40 +
import io.oxygenhub.android.api.Comment
41 +
import io.oxygenhub.android.ui.common.AppScreen
42 +
import io.oxygenhub.android.ui.common.Load
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 +
47 +
/**
48 +
 * An issue (#4): its body and comments rendered, its labels and
49 +
 * assignees; comment, close or reopen, and change its labels, when the
50 +
 * session may write.
51 +
 */
52 +
@OptIn(ExperimentalLayoutApi::class)
53 +
@Composable
54 +
fun IssueScreen(vm: IssueViewModel, canWrite: Boolean, onBack: () -> Unit) {
55 +
    val state by vm.state.collectAsStateWithLifecycle()
56 +
    val working by vm.working.collectAsStateWithLifecycle()
57 +
    val error by vm.error.collectAsStateWithLifecycle()
58 +
    var picking by remember { mutableStateOf(false) }
59 +
    val status = LocalStatusColors.current
60 +
    AppScreen(
61 +
        title = "#${vm.number}",
62 +
        subtitle = "${vm.owner}/${vm.name}",
63 +
        onBack = onBack,
64 +
        actions = {
65 +
            if (canWrite) {
66 +
                IconButton(onClick = {
67 +
                    vm.loadLabels()
68 +
                    picking = true
69 +
                }) { Icon(Icons.AutoMirrored.Outlined.Label, contentDescription = "Labels") }
70 +
            }
71 +
        },
72 +
    ) {
73 +
        LoadContent(state, onRefresh = vm::refresh) { t ->
74 +
            val i = t.issue
75 +
            LazyColumn(Modifier.imePadding()) {
76 +
                item {
77 +
                    Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
78 +
                        Text(i.title, style = MaterialTheme.typography.titleLarge)
79 +
                        Text(
80 +
                            (if (i.isOpen) "Open" else "Closed" + if (i.reason == "not_planned") " (not planned)" else "") +
81 +
                                " · opened ${relativeTime(i.createdMs)} by ${i.author}",
82 +
                            color = if (i.isOpen) status.ok else status.warn,
83 +
                            style = MaterialTheme.typography.bodyMedium,
84 +
                        )
85 +
                        if (i.labels.isNotEmpty()) {
86 +
                            FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
87 +
                                i.labels.forEach { LabelChip(it) }
88 +
                            }
89 +
                        }
90 +
                        if (i.assignees.isNotEmpty()) Text("Assigned to " + i.assignees.joinToString(", "), style = MaterialTheme.typography.bodySmall)
91 +
                        i.milestone?.let { Text("Milestone: $it", style = MaterialTheme.typography.bodySmall) }
92 +
                        if (i.body.isNotBlank()) Markdown(i.body) else Text("No description.", color = MaterialTheme.colorScheme.onSurfaceVariant)
93 +
                    }
94 +
                    HorizontalDivider()
95 +
                }
96 +
                items(t.comments, key = { it.id }) { c -> CommentCard(c) }
97 +
                item {
98 +
                    error?.let {
99 +
                        Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
100 +
                    }
101 +
                    if (canWrite && !i.locked) {
102 +
                        CommentBox(working, isOpen = i.isOpen, onComment = { body, done -> vm.comment(body, done) }, onClose = { vm.close() }, onReopen = vm::reopen)
103 +
                    } else if (i.locked) {
104 +
                        Text("Locked: only collaborators may comment.", modifier = Modifier.padding(16.dp))
105 +
                    }
106 +
                }
107 +
            }
108 +
        }
109 +
    }
110 +
    if (picking) {
111 +
        LabelPicker(vm, current = (state as? Load.Ready)?.value?.issue?.labels?.map { it.name }.orEmpty(), onDone = { picking = false })
112 +
    }
113 +
}
114 +
115 +
@Composable
116 +
private fun CommentCard(c: Comment) {
117 +
    ElevatedCard(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp)) {
118 +
        Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
119 +
            Text("${c.author} · ${relativeTime(c.createdMs)}", style = MaterialTheme.typography.labelMedium)
120 +
            Markdown(c.body)
121 +
        }
122 +
    }
123 +
}
124 +
125 +
@Composable
126 +
private fun CommentBox(working: Boolean, isOpen: Boolean, onComment: (String, (Boolean) -> Unit) -> Unit, onClose: () -> Unit, onReopen: () -> Unit) {
127 +
    var text by rememberSaveable { mutableStateOf("") }
128 +
    Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
129 +
        OutlinedTextField(text, { text = it }, label = { Text("Comment (Markdown)") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
130 +
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
131 +
            Button(enabled = !working && text.isNotBlank(), onClick = { onComment(text) { ok -> if (ok) text = "" } }) { Text("Comment") }
132 +
            if (isOpen) {
133 +
                OutlinedButton(enabled = !working, onClick = onClose) { Text("Close issue") }
134 +
            } else {
135 +
                OutlinedButton(enabled = !working, onClick = onReopen) { Text("Reopen") }
136 +
            }
137 +
        }
138 +
    }
139 +
}
140 +
141 +
/** The repository's labels, ticked where the issue has them; saved as the whole set. */
142 +
@Composable
143 +
private fun LabelPicker(vm: IssueViewModel, current: List<String>, onDone: () -> Unit) {
144 +
    val labels by vm.labels.collectAsStateWithLifecycle()
145 +
    val chosen = remember { mutableStateListOf<String>().apply { addAll(current) } }
146 +
    LaunchedEffect(Unit) { vm.loadLabels() }
147 +
    AlertDialog(
148 +
        onDismissRequest = onDone,
149 +
        title = { Text("Labels") },
150 +
        text = {
151 +
            when (val l = labels) {
152 +
                is Load.Ready -> LazyColumn {
153 +
                    items(l.value, key = { it.name }) { label ->
154 +
                        Row(verticalAlignment = Alignment.CenterVertically) {
155 +
                            Checkbox(label.name in chosen, onCheckedChange = { on -> if (on) chosen += label.name else chosen -= label.name })
156 +
                            LabelChip(label)
157 +
                        }
158 +
                    }
159 +
                }
160 +
                is Load.Failed -> Text(l.message)
161 +
                Load.Loading -> Text("Loading…")
162 +
            }
163 +
        },
164 +
        confirmButton = {
165 +
            TextButton(onClick = {
166 +
                vm.setLabels(chosen.toList())
167 +
                onDone()
168 +
            }) { Text("Save") }
169 +
        },
170 +
        dismissButton = { TextButton(onClick = onDone) { Text("Cancel") } },
171 +
    )
172 +
}
173 +
174 +
/** A new issue's title and description; opened, it shows on its own screen. */
175 +
@Composable
176 +
fun NewIssueScreen(vm: NewIssueViewModel, onBack: () -> Unit, onOpened: (Long) -> Unit) {
177 +
    val working by vm.working.collectAsStateWithLifecycle()
178 +
    val error by vm.error.collectAsStateWithLifecycle()
179 +
    var title by rememberSaveable { mutableStateOf("") }
180 +
    var body by rememberSaveable { mutableStateOf("") }
181 +
    AppScreen(title = "New issue", subtitle = "${vm.owner}/${vm.name}", onBack = onBack) {
182 +
        Column(Modifier.padding(16.dp).imePadding(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
183 +
            OutlinedTextField(title, { title = it }, label = { Text("Title") }, singleLine = true, modifier = Modifier.fillMaxWidth())
184 +
            OutlinedTextField(body, { body = it }, label = { Text("Description (Markdown)") }, minLines = 6, modifier = Modifier.fillMaxWidth())
185 +
            error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
186 +
            Button(enabled = !working && title.isNotBlank(), onClick = { vm.open(title, body) { onOpened(it.number) } }) { Text("Open issue") }
187 +
        }
188 +
    }
189 +
}
added app/src/main/java/io/oxygenhub/android/ui/issues/IssueViewModel.kt +139 −0
1 +
package io.oxygenhub.android.ui.issues
2 +
3 +
import androidx.lifecycle.ViewModel
4 +
import androidx.lifecycle.viewModelScope
5 +
import io.oxygenhub.android.api.Comment
6 +
import io.oxygenhub.android.api.HubApi
7 +
import io.oxygenhub.android.api.Issue
8 +
import io.oxygenhub.android.api.Label
9 +
import io.oxygenhub.android.api.userMessage
10 +
import io.oxygenhub.android.ui.common.Load
11 +
import io.oxygenhub.android.ui.common.load
12 +
import kotlinx.coroutines.CancellationException
13 +
import kotlinx.coroutines.Job
14 +
import kotlinx.coroutines.async
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 +
 * One issue (#4): it and its comments; comment, close or reopen, and set
23 +
 * its labels. What the hub refuses is said in [error], and the page stays.
24 +
 */
25 +
class IssueViewModel(
26 +
    private val api: HubApi,
27 +
    val owner: String,
28 +
    val name: String,
29 +
    val number: Long,
30 +
) : ViewModel() {
31 +
    data class Thread(val issue: Issue, val comments: List<Comment>)
32 +
33 +
    private val _state = MutableStateFlow<Load<Thread>>(Load.Loading)
34 +
    val state: StateFlow<Load<Thread>> = _state.asStateFlow()
35 +
36 +
    private val _working = MutableStateFlow(false)
37 +
    val working: StateFlow<Boolean> = _working.asStateFlow()
38 +
39 +
    private val _error = MutableStateFlow<String?>(null)
40 +
    val error: StateFlow<String?> = _error.asStateFlow()
41 +
42 +
    /** The repository's labels, once the label picker asks. */
43 +
    private val _labels = MutableStateFlow<Load<List<Label>>>(Load.Loading)
44 +
    val labels: StateFlow<Load<List<Label>>> = _labels.asStateFlow()
45 +
46 +
    private var job: Job? = null
47 +
    private var labelsJob: Job? = null
48 +
49 +
    init {
50 +
        refresh()
51 +
    }
52 +
53 +
    fun refresh() {
54 +
        job = load(_state, job) {
55 +
            coroutineScope {
56 +
                val issue = async { api.issue(owner, name, number) }
57 +
                val comments = async { api.issueComments(owner, name, number) }
58 +
                Thread(issue.await(), comments.await())
59 +
            }
60 +
        }
61 +
    }
62 +
63 +
    fun loadLabels() {
64 +
        if (_labels.value is Load.Ready) return
65 +
        labelsJob = load(_labels, labelsJob) { api.labels(owner, name) }
66 +
    }
67 +
68 +
    /** Post [body]; true once the hub has it (the box empties then). */
69 +
    fun comment(body: String, then: (Boolean) -> Unit = {}) = act({
70 +
        val c = api.comment(owner, name, number, body.trim())
71 +
        current()?.let { t -> _state.value = Load.Ready(t.copy(comments = t.comments + c, issue = t.issue.copy(comments = t.issue.comments + 1))) }
72 +
    }, then)
73 +
74 +
    fun close(reason: String = "completed") = act({ swap(api.setIssueState(owner, name, number, open = false, reason = reason)) })
75 +
76 +
    fun reopen() = act({ swap(api.setIssueState(owner, name, number, open = true)) })
77 +
78 +
    fun setLabels(names: List<String>) = act({ swap(api.setIssueLabels(owner, name, number, names)) })
79 +
80 +
    fun dismissError() {
81 +
        _error.value = null
82 +
    }
83 +
84 +
    private fun current(): Thread? = (_state.value as? Load.Ready)?.value
85 +
86 +
    private fun swap(issue: Issue) {
87 +
        current()?.let { _state.value = Load.Ready(it.copy(issue = issue)) }
88 +
    }
89 +
90 +
    private fun act(step: suspend () -> Unit, then: (Boolean) -> Unit = {}) {
91 +
        if (_working.value) return
92 +
        _working.value = true
93 +
        _error.value = null
94 +
        viewModelScope.launch {
95 +
            val ok = try {
96 +
                step()
97 +
                true
98 +
            } catch (e: CancellationException) {
99 +
                throw e
100 +
            } catch (e: Exception) {
101 +
                _error.value = e.userMessage()
102 +
                false
103 +
            } finally {
104 +
                _working.value = false
105 +
            }
106 +
            then(ok)
107 +
        }
108 +
    }
109 +
}
110 +
111 +
/** A new issue: its title and body, opened on the hub. */
112 +
class NewIssueViewModel(
113 +
    private val api: HubApi,
114 +
    val owner: String,
115 +
    val name: String,
116 +
) : ViewModel() {
117 +
    private val _working = MutableStateFlow(false)
118 +
    val working: StateFlow<Boolean> = _working.asStateFlow()
119 +
120 +
    private val _error = MutableStateFlow<String?>(null)
121 +
    val error: StateFlow<String?> = _error.asStateFlow()
122 +
123 +
    fun open(title: String, body: String, opened: (Issue) -> Unit) {
124 +
        if (_working.value || title.isBlank()) return
125 +
        _working.value = true
126 +
        _error.value = null
127 +
        viewModelScope.launch {
128 +
            try {
129 +
                opened(api.createIssue(owner, name, title.trim(), body.trim()))
130 +
            } catch (e: CancellationException) {
131 +
                throw e
132 +
            } catch (e: Exception) {
133 +
                _error.value = e.userMessage()
134 +
            } finally {
135 +
                _working.value = false
136 +
            }
137 +
        }
138 +
    }
139 +
}
added app/src/test/java/io/oxygenhub/android/ui/IssueViewModelTest.kt +95 −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.Comment
6 +
import io.oxygenhub.android.api.HubException
7 +
import io.oxygenhub.android.api.Issue
8 +
import io.oxygenhub.android.ui.common.Load
9 +
import io.oxygenhub.android.ui.issues.IssueViewModel
10 +
import io.oxygenhub.android.ui.issues.NewIssueViewModel
11 +
import org.junit.Assert.assertEquals
12 +
import org.junit.Assert.assertFalse
13 +
import org.junit.Assert.assertNull
14 +
import org.junit.Assert.assertTrue
15 +
import org.junit.Rule
16 +
import org.junit.Test
17 +
18 +
class IssueViewModelTest {
19 +
    @get:Rule val main = MainDispatcherRule()
20 +
21 +
    private val api = FakeHubApi().apply {
22 +
        issueStore[7] = Issue(7, "Crash on start", "It **crashes**.", author = "bob", comments = 1)
23 +
        commentStore[7] = mutableListOf(Comment(1, "bob", "Still happens"))
24 +
    }
25 +
26 +
    private fun thread(vm: IssueViewModel) = (vm.state.value as Load.Ready).value
27 +
28 +
    @Test
29 +
    fun showsTheIssueAndItsComments() {
30 +
        val vm = IssueViewModel(api, "andymccall", "demo", 7)
31 +
        assertEquals("Crash on start", thread(vm).issue.title)
32 +
        assertEquals(listOf("Still happens"), thread(vm).comments.map { it.body })
33 +
    }
34 +
35 +
    @Test
36 +
    fun aCommentJoinsTheThreadAndTheBoxEmpties() {
37 +
        val vm = IssueViewModel(api, "andymccall", "demo", 7)
38 +
        var done: Boolean? = null
39 +
        vm.comment("  Fixed in 4f07c5a0  ") { done = it }
40 +
        assertEquals(true, done)
41 +
        assertEquals(listOf("Still happens", "Fixed in 4f07c5a0"), thread(vm).comments.map { it.body })
42 +
        assertEquals(2, thread(vm).issue.comments)
43 +
        assertFalse(vm.working.value)
44 +
    }
45 +
46 +
    @Test
47 +
    fun closingAndReopeningChangeItsState() {
48 +
        val vm = IssueViewModel(api, "andymccall", "demo", 7)
49 +
        vm.close("not_planned")
50 +
        assertFalse(thread(vm).issue.isOpen)
51 +
        assertEquals("not_planned", thread(vm).issue.reason)
52 +
        assertTrue("state 7 closed:not_planned" in api.calls)
53 +
        vm.reopen()
54 +
        assertTrue(thread(vm).issue.isOpen)
55 +
    }
56 +
57 +
    @Test
58 +
    fun labelsAreSetAsAWholeSet() {
59 +
        val vm = IssueViewModel(api, "andymccall", "demo", 7)
60 +
        vm.loadLabels()
61 +
        assertEquals(listOf("bug", "ui"), (vm.labels.value as Load.Ready).value.map { it.name })
62 +
        vm.setLabels(listOf("bug"))
63 +
        assertEquals(listOf("bug"), thread(vm).issue.labels.map { it.name })
64 +
    }
65 +
66 +
    @Test
67 +
    fun whatTheHubRefusesIsSaidAndThePageStays() {
68 +
        val vm = IssueViewModel(api, "andymccall", "demo", 7)
69 +
        api.commentStore.clear()
70 +
        val failing = object : io.oxygenhub.android.api.HubApi by api {
71 +
            override suspend fun comment(owner: String, name: String, number: Long, body: String): Comment =
72 +
                throw HubException.Http(403, "the issue is locked")
73 +
        }
74 +
        val locked = IssueViewModel(failing, "andymccall", "demo", 7)
75 +
        var done: Boolean? = null
76 +
        locked.comment("hello") { done = it }
77 +
        assertEquals(false, done)
78 +
        assertTrue(locked.error.value!!.contains("locked"))
79 +
        assertEquals("Crash on start", thread(locked).issue.title)
80 +
        locked.dismissError()
81 +
        assertNull(locked.error.value)
82 +
        assertEquals("Crash on start", thread(vm).issue.title)
83 +
    }
84 +
85 +
    @Test
86 +
    fun aNewIssueOpensWithItsTitle() {
87 +
        val vm = NewIssueViewModel(api, "andymccall", "demo")
88 +
        var opened: Issue? = null
89 +
        vm.open("  ", "body") { opened = it }
90 +
        assertNull("no title, nothing opened", opened)
91 +
        vm.open(" Board grows ", "Done keeps growing") { opened = it }
92 +
        assertEquals("Board grows", opened?.title)
93 +
        assertEquals(8L, opened?.number)
94 +
    }
95 +
}
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +67 −1
1 1
package io.oxygenhub.android.api
2 2
3 +
import java.io.IOException
3 4
import kotlinx.serialization.KSerializer
4 5
import kotlinx.serialization.builtins.ListSerializer
6 +
import kotlinx.serialization.json.JsonArray
7 +
import kotlinx.serialization.json.JsonObject
8 +
import kotlinx.serialization.json.JsonPrimitive
9 +
import kotlinx.serialization.json.buildJsonObject
10 +
import kotlinx.serialization.json.put
5 11
import okhttp3.HttpUrl
6 12
import okhttp3.HttpUrl.Companion.toHttpUrl
7 13
import okhttp3.OkHttpClient
8 14
import okhttp3.Request
9 15
import okhttp3.RequestBody.Companion.toRequestBody
10 -
import java.io.IOException
11 16
12 17
/**
13 18
 * What the app asks of a hub, signed in. The view models see only this, so
…
47 unchanged lines
61 66
62 67
    /** A one-time token registering this phone as a runner for the account's repositories (`repo:write`). */
63 68
    suspend fun runnerToken(): RunnerToken
69 +
70 +
    suspend fun issue(owner: String, name: String, number: Long): Issue
71 +
72 +
    suspend fun issueComments(owner: String, name: String, number: Long): List<Comment>
73 +
74 +
    suspend fun comment(owner: String, name: String, number: Long, body: String): Comment
75 +
76 +
    /** Close (with why: `completed` or `not_planned`) or reopen. */
77 +
    suspend fun setIssueState(owner: String, name: String, number: Long, open: Boolean, reason: String = "completed"): Issue
78 +
79 +
    suspend fun createIssue(owner: String, name: String, title: String, body: String): Issue
64 80
81 +
    /** The repository's labels, to choose from. */
82 +
    suspend fun labels(owner: String, name: String): List<Label>
83 +
84 +
    /** The issue's whole set of labels (triage). */
85 +
    suspend fun setIssueLabels(owner: String, name: String, number: Long, labels: List<String>): Issue
86 +
65 87
    /** One repository (`GET /repos/{owner}/{name}`): its default bookmark, for the code browser. */
66 88
    suspend fun repository(owner: String, name: String): Repository
67 89
…
40 unchanged lines
108 130
        get(url("notifications") { addQueryParameter("limit", limit.coerceIn(1, 200).toString()) }, Inbox.serializer())
109 131
110 132
    override suspend fun runnerToken(): RunnerToken = send("POST", url("user", "runner-token"), RunnerToken.serializer())
133 +
134 +
    override suspend fun issue(owner: String, name: String, number: Long): Issue =
135 +
        get(url("repos", owner, name, "issues", number.toString()), Issue.serializer())
111 136
137 +
    override suspend fun issueComments(owner: String, name: String, number: Long): List<Comment> =
138 +
        get(url("repos", owner, name, "issues", number.toString(), "comments"), ListSerializer(Comment.serializer()))
139 +
140 +
    override suspend fun comment(owner: String, name: String, number: Long, body: String): Comment =
141 +
        sendJson("POST", url("repos", owner, name, "issues", number.toString(), "comments"), buildJsonObject { put("body", body) }, Comment.serializer())
142 +
143 +
    override suspend fun setIssueState(owner: String, name: String, number: Long, open: Boolean, reason: String): Issue =
144 +
        sendJson(
145 +
            "POST",
146 +
            url("repos", owner, name, "issues", number.toString()),
147 +
            buildJsonObject {
148 +
                put("state", if (open) "open" else "closed")
149 +
                if (!open) put("reason", reason)
150 +
            },
151 +
            Issue.serializer(),
152 +
        )
153 +
154 +
    override suspend fun createIssue(owner: String, name: String, title: String, body: String): Issue =
155 +
        sendJson("POST", url("repos", owner, name, "issues"), buildJsonObject {
156 +
            put("title", title)
157 +
            put("body", body)
158 +
        }, Issue.serializer())
159 +
160 +
    override suspend fun labels(owner: String, name: String): List<Label> =
161 +
        get(url("repos", owner, name, "labels"), ListSerializer(Label.serializer()))
162 +
163 +
    override suspend fun setIssueLabels(owner: String, name: String, number: Long, labels: List<String>): Issue =
164 +
        sendJson("PATCH", url("repos", owner, name, "issues", number.toString()), buildJsonObject {
165 +
            put("labels", JsonArray(labels.map { JsonPrimitive(it) }))
166 +
        }, Issue.serializer())
167 +
112 168
    override suspend fun repository(owner: String, name: String): Repository = get(url("repos", owner, name), Repository.serializer())
113 169
114 170
    private fun parts(path: String): Array<String> = path.split('/').filter { it.isNotEmpty() }.toTypedArray()
…
103 unchanged lines
218 274
219 275
    private suspend fun <T> send(method: String, url: HttpUrl, serializer: KSerializer<T>): T {
220 276
        val body = send(method, url)
277 +
        return try {
278 +
            HubJson.decodeFromString(serializer, body)
279 +
        } catch (e: IllegalArgumentException) {
280 +
            throw HubException.Unreadable("$method ${url.encodedPath}", e)
281 +
        }
282 +
    }
283 +
284 +
    /** [json] as the body; the answer read with [serializer]. */
285 +
    private suspend fun <T> sendJson(method: String, url: HttpUrl, json: JsonObject, serializer: KSerializer<T>): T {
286 +
        val body = call(request(url).method(method, json.toString().toRequestBody(JsonMedia)).build(), notifyUnauthorized = true)
221 287
        return try {
222 288
            HubJson.decodeFromString(serializer, body)
223 289
        } catch (e: IllegalArgumentException) {
…
29 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +9 −0
…
76 unchanged lines
77 77
    val isOpen: Boolean get() = state == "open"
78 78
}
79 79
80 +
/** A comment on an issue or proposal. */
81 +
@Serializable
82 +
data class Comment(
83 +
    val id: Long = 0,
84 +
    val author: String = "",
85 +
    val body: String = "",
86 +
    @SerialName("created_ms") val createdMs: Long = 0,
87 +
)
88 +
80 89
/** Which issues to list: the hub's `?state=`. */
81 90
enum class IssueState(val query: String) { Open("open"), Closed("closed"), All("all") }
82 91
…
235 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/issues/IssuesScreen.kt +9 −6
…
24 unchanged lines
25 25
import androidx.compose.material3.ListItem
26 26
import androidx.compose.material3.MaterialTheme
27 27
import androidx.compose.material3.Text
28 +
import androidx.compose.material3.TextButton
28 29
import androidx.compose.runtime.Composable
29 30
import androidx.compose.runtime.getValue
30 31
import androidx.compose.ui.Alignment
…
1 unchanged lines
32 33
import androidx.compose.ui.draw.clip
33 34
import androidx.compose.ui.graphics.Color
34 35
import androidx.compose.ui.graphics.luminance
35 -
import androidx.compose.ui.platform.LocalUriHandler
36 36
import androidx.compose.ui.text.style.TextOverflow
37 37
import androidx.compose.ui.unit.dp
38 38
import androidx.compose.ui.unit.sp
…
7 unchanged lines
46 46
import io.oxygenhub.android.ui.theme.LocalStatusColors
47 47
48 48
/**
49 -
 * A repository's issues with their filter, for the repository's tab. An
50 -
 * issue opens on the hub's web page until #4 brings the issue screen.
49 +
 * A repository's issues with their filter, for the repository's tab; an
50 +
 * issue opens on its own screen (#4), and [onNewIssue] starts one.
51 51
 */
52 52
@Composable
53 -
fun IssueList(vm: IssuesViewModel) {
53 +
fun IssueList(vm: IssuesViewModel, onOpenIssue: (Long) -> Unit, onNewIssue: (() -> Unit)?) {
54 54
    val state by vm.state.collectAsStateWithLifecycle()
55 55
    val filter by vm.filter.collectAsStateWithLifecycle()
56 -
    val uri = LocalUriHandler.current
57 56
    Column(Modifier.fillMaxSize()) {
58 57
        Row(
59 58
            Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp),
60 59
            horizontalArrangement = Arrangement.spacedBy(8.dp),
60 +
            verticalAlignment = Alignment.CenterVertically,
61 61
        ) {
62 62
            IssueState.entries.forEach { s ->
63 63
                FilterChip(selected = filter == s, onClick = { vm.setFilter(s) }, label = { Text(s.name) })
64 64
            }
65 +
            if (onNewIssue != null) {
66 +
                TextButton(onClick = onNewIssue) { Text("New issue") }
67 +
            }
65 68
        }
66 69
        LoadContent(state, onRefresh = vm::refresh) { issues ->
67 70
            if (issues.isEmpty()) {
…
9 unchanged lines
77 80
            } else {
78 81
                LazyColumn(Modifier.fillMaxSize()) {
79 82
                    items(issues, key = { it.number }) { issue ->
80 -
                        IssueRow(issue, onClick = { if (issue.url.isNotBlank()) uri.openUri(issue.url) })
83 +
                        IssueRow(issue, onClick = { onOpenIssue(issue.number) })
81 84
                        HorizontalDivider()
82 85
                    }
83 86
                }
…
75 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +25 −0
…
47 unchanged lines
48 48
import io.oxygenhub.android.ui.dashboard.DashboardViewModel
49 49
import io.oxygenhub.android.ui.inbox.InboxScreen
50 50
import io.oxygenhub.android.ui.inbox.InboxViewModel
51 +
import io.oxygenhub.android.ui.issues.IssueScreen
52 +
import io.oxygenhub.android.ui.issues.IssueViewModel
51 53
import io.oxygenhub.android.ui.issues.IssuesViewModel
54 +
import io.oxygenhub.android.ui.issues.NewIssueScreen
55 +
import io.oxygenhub.android.ui.issues.NewIssueViewModel
52 56
import io.oxygenhub.android.ui.proposals.ProposalScreen
53 57
import io.oxygenhub.android.ui.proposals.ProposalViewModel
54 58
import io.oxygenhub.android.ui.proposals.ProposalsViewModel
…
30 unchanged lines
85 89
86 90
@Serializable data class ChangeRoute(val owner: String, val name: String, val hash: String)
87 91
92 +
/** An issue, and a new one (#4). */
93 +
@Serializable data class IssueRoute(val owner: String, val name: String, val number: Long)
94 +
95 +
@Serializable data class NewIssueRoute(val owner: String, val name: String)
96 +
88 97
enum class TopLevel(val label: String, val icon: ImageVector, val selectedIcon: ImageVector, val route: Any, val routeClass: KClass<*>) {
89 98
    Dashboard("Dashboard", Icons.Outlined.Dashboard, Icons.Filled.Dashboard, DashboardRoute, DashboardRoute::class),
90 99
    Repositories("Repositories", Icons.Outlined.Book, Icons.Filled.Book, RepositoriesRoute, RepositoriesRoute::class),
…
74 unchanged lines
165 174
                        onOpenProposal = { nav.navigate(ProposalRoute(owner, name, it.number)) },
166 175
                        onOpenFile = { ref, path -> nav.navigate(FileRoute(owner, name, ref, path)) },
167 176
                        onOpenHistory = { ref -> nav.navigate(HistoryRoute(owner, name, ref)) },
177 +
                        onOpenIssue = { nav.navigate(IssueRoute(owner, name, it)) },
178 +
                        onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(owner, name)) } } else null,
168 179
                    )
169 180
                }
170 181
            }
…
7 unchanged lines
178 189
                    onOpenProposal = { nav.navigate(ProposalRoute(r.owner, r.name, it.number)) },
179 190
                    onOpenFile = { ref, path -> nav.navigate(FileRoute(r.owner, r.name, ref, path)) },
180 191
                    onOpenHistory = { ref -> nav.navigate(HistoryRoute(r.owner, r.name, ref)) },
192 +
                    onOpenIssue = { nav.navigate(IssueRoute(r.owner, r.name, it)) },
193 +
                    onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(r.owner, r.name)) } } else null,
194 +
                )
195 +
            }
196 +
            composable<IssueRoute> { e ->
197 +
                val r = e.toRoute<IssueRoute>()
198 +
                IssueScreen(rememberViewModel { IssueViewModel(api, r.owner, r.name, r.number) }, canWrite = session.canWrite, onBack = { nav.popBackStack() })
199 +
            }
200 +
            composable<NewIssueRoute> { e ->
201 +
                val r = e.toRoute<NewIssueRoute>()
202 +
                NewIssueScreen(
203 +
                    rememberViewModel { NewIssueViewModel(api, r.owner, r.name) },
204 +
                    onBack = { nav.popBackStack() },
205 +
                    onOpened = { n -> nav.navigate(IssueRoute(r.owner, r.name, n)) { popUpTo<NewIssueRoute> { inclusive = true } } },
181 206
                )
182 207
            }
183 208
            composable<FileRoute> { e ->
…
54 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/repos/RepositoryScreen.kt +3 −1
…
31 unchanged lines
32 32
    onOpenProposal: (Proposal) -> Unit,
33 33
    onOpenFile: (ref: String, path: String) -> Unit,
34 34
    onOpenHistory: (ref: String) -> Unit,
35 +
    onOpenIssue: (Long) -> Unit,
36 +
    onNewIssue: (() -> Unit)?,
35 37
    initialTab: RepositoryTab = RepositoryTab.Code,
36 38
) {
37 39
    var tab by rememberSaveable { mutableIntStateOf(initialTab.ordinal) }
…
6 unchanged lines
44 46
            }
45 47
            when (RepositoryTab.entries[tab]) {
46 48
                RepositoryTab.Code -> CodeBrowser(code, onOpenFile, onOpenHistory)
47 -
                RepositoryTab.Issues -> IssueList(issues)
49 +
                RepositoryTab.Issues -> IssueList(issues, onOpenIssue, onNewIssue)
48 50
                RepositoryTab.Proposals -> ProposalList(proposals, onOpenProposal)
49 51
            }
50 52
        }
…
2 unchanged lines
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +32 −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 +
    val issueStore = mutableMapOf<Long, io.oxygenhub.android.api.Issue>()
63 +
    val commentStore = mutableMapOf<Long, MutableList<io.oxygenhub.android.api.Comment>>()
64 +
    var repoLabels = listOf(io.oxygenhub.android.api.Label("bug", "#c62d47"), io.oxygenhub.android.api.Label("ui", "#0969da"))
65 +
    override suspend fun issue(owner: String, name: String, number: Long) =
66 +
        (issueStore[number] ?: throw io.oxygenhub.android.api.HubException.Http(404, "no such issue")).also { calls += "issue $number" }
67 +
    override suspend fun issueComments(owner: String, name: String, number: Long) = commentStore[number].orEmpty().toList()
68 +
    override suspend fun comment(owner: String, name: String, number: Long, body: String): io.oxygenhub.android.api.Comment {
69 +
        calls += "comment $number"
70 +
        val c = io.oxygenhub.android.api.Comment(id = (commentStore.values.sumOf { it.size } + 1).toLong(), author = "andymccall", body = body)
71 +
        commentStore.getOrPut(number) { mutableListOf() } += c
72 +
        issueStore[number]?.let { issueStore[number] = it.copy(comments = it.comments + 1) }
73 +
        return c
74 +
    }
75 +
    override suspend fun setIssueState(owner: String, name: String, number: Long, open: Boolean, reason: String): io.oxygenhub.android.api.Issue {
76 +
        calls += "state $number ${if (open) "open" else "closed:$reason"}"
77 +
        val i = issueStore.getValue(number).copy(state = if (open) "open" else "closed", reason = if (open) "" else reason)
78 +
        issueStore[number] = i
79 +
        return i
80 +
    }
81 +
    override suspend fun createIssue(owner: String, name: String, title: String, body: String): io.oxygenhub.android.api.Issue {
82 +
        val n = (issueStore.keys.maxOrNull() ?: 0) + 1
83 +
        val i = io.oxygenhub.android.api.Issue(n, title, body, author = "andymccall")
84 +
        issueStore[n] = i
85 +
        calls += "create $title"
86 +
        return i
87 +
    }
88 +
    override suspend fun labels(owner: String, name: String) = repoLabels
89 +
    override suspend fun setIssueLabels(owner: String, name: String, number: Long, labels: List<String>): io.oxygenhub.android.api.Issue {
90 +
        val i = issueStore.getValue(number).copy(labels = repoLabels.filter { it.name in labels })
91 +
        issueStore[number] = i
92 +
        return i
93 +
    }
62 94
    var repository: io.oxygenhub.android.api.Repository = io.oxygenhub.android.api.Repository("andymccall", "demo", defaultBookmark = "main")
63 95
    override suspend fun repository(owner: String, name: String) = repository.also { calls += "repository" }
64 96
    var tree: (String, String) -> List<io.oxygenhub.android.api.TreeEntry> = { _, _ -> emptyList() }
…
36 unchanged lines