OXYGENHUB
# 740a452b2669 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / issues / IssueScreen.kt
11.7 KiB · last changed Assign issues from the phone: the Assignees action, with Assign me and any username, saved as the whole set (refs #4) 8 days ago source blame history raw
1
package io.oxygenhub.android.ui.issues
2
3
import androidx.compose.material.icons.outlined.PersonAdd
4
import androidx.compose.material.icons.outlined.Close
5
import androidx.compose.material.icons.outlined.Add
6
import androidx.compose.foundation.layout.Arrangement
7
import androidx.compose.foundation.layout.Column
8
import androidx.compose.foundation.layout.ExperimentalLayoutApi
9
import androidx.compose.foundation.layout.FlowRow
10
import androidx.compose.foundation.layout.Row
11
import androidx.compose.foundation.layout.fillMaxWidth
12
import androidx.compose.foundation.layout.imePadding
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.material.icons.Icons
17
import androidx.compose.material.icons.automirrored.outlined.Label
18
import androidx.compose.material3.AlertDialog
19
import androidx.compose.material3.Button
20
import androidx.compose.material3.Checkbox
21
import androidx.compose.material3.ElevatedCard
22
import androidx.compose.material3.HorizontalDivider
23
import androidx.compose.material3.Icon
24
import androidx.compose.material3.IconButton
25
import androidx.compose.material3.MaterialTheme
26
import androidx.compose.material3.OutlinedButton
27
import androidx.compose.material3.OutlinedTextField
28
import androidx.compose.material3.Text
29
import androidx.compose.material3.TextButton
30
import androidx.compose.runtime.Composable
31
import androidx.compose.runtime.LaunchedEffect
32
import androidx.compose.runtime.getValue
33
import androidx.compose.runtime.mutableStateListOf
34
import androidx.compose.runtime.mutableStateOf
35
import androidx.compose.runtime.remember
36
import androidx.compose.runtime.saveable.rememberSaveable
37
import androidx.compose.runtime.setValue
38
import androidx.compose.ui.Alignment
39
import androidx.compose.ui.Modifier
40
import androidx.compose.ui.unit.dp
41
import androidx.lifecycle.compose.collectAsStateWithLifecycle
42
import com.mikepenz.markdown.m3.Markdown
43
import io.oxygenhub.android.api.Comment
44
import io.oxygenhub.android.ui.common.AppScreen
45
import io.oxygenhub.android.ui.common.Load
46
import io.oxygenhub.android.ui.common.LoadContent
47
import io.oxygenhub.android.ui.common.relativeTime
48
import io.oxygenhub.android.ui.theme.LocalStatusColors
49
50
/**
51
 * An issue (#4): its body and comments rendered, its labels and
52
 * assignees; comment, close or reopen, and change its labels, when the
53
 * session may write.
54
 */
55
@OptIn(ExperimentalLayoutApi::class)
56
@Composable
57
fun IssueScreen(vm: IssueViewModel, canWrite: Boolean, onBack: () -> Unit, me: String = "") {
58
    val state by vm.state.collectAsStateWithLifecycle()
59
    val working by vm.working.collectAsStateWithLifecycle()
60
    val error by vm.error.collectAsStateWithLifecycle()
61
    var picking by remember { mutableStateOf(false) }
62
    var assigning by remember { mutableStateOf(false) }
63
    val status = LocalStatusColors.current
64
    AppScreen(
65
        title = "#${vm.number}",
66
        subtitle = "${vm.owner}/${vm.name}",
67
        onBack = onBack,
68
        actions = {
69
            if (canWrite) {
70
                IconButton(onClick = {
71
                    vm.loadLabels()
72
                    picking = true
73
                }) { Icon(Icons.AutoMirrored.Outlined.Label, contentDescription = "Labels") }
74
                IconButton(onClick = { assigning = true }) { Icon(Icons.Outlined.PersonAdd, contentDescription = "Assignees") }
75
            }
76
        },
77
    ) {
78
        LoadContent(state, onRefresh = vm::refresh) { t ->
79
            val i = t.issue
80
            LazyColumn(Modifier.imePadding()) {
81
                item {
82
                    Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
83
                        Text(i.title, style = MaterialTheme.typography.titleLarge)
84
                        Text(
85
                            (if (i.isOpen) "Open" else "Closed" + if (i.reason == "not_planned") " (not planned)" else "") +
86
                                " · opened ${relativeTime(i.createdMs)} by ${i.author}",
87
                            color = if (i.isOpen) status.ok else status.warn,
88
                            style = MaterialTheme.typography.bodyMedium,
89
                        )
90
                        if (i.labels.isNotEmpty()) {
91
                            FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
92
                                i.labels.forEach { LabelChip(it) }
93
                            }
94
                        }
95
                        if (i.assignees.isNotEmpty()) Text("Assigned to " + i.assignees.joinToString(", "), style = MaterialTheme.typography.bodySmall)
96
                        i.milestone?.let { Text("Milestone: $it", style = MaterialTheme.typography.bodySmall) }
97
                        if (i.body.isNotBlank()) Markdown(i.body) else Text("No description.", color = MaterialTheme.colorScheme.onSurfaceVariant)
98
                    }
99
                    HorizontalDivider()
100
                }
101
                items(t.comments, key = { it.id }) { c -> CommentCard(c) }
102
                item {
103
                    error?.let {
104
                        Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
105
                    }
106
                    if (canWrite && !i.locked) {
107
                        CommentBox(working, isOpen = i.isOpen, onComment = { body, done -> vm.comment(body, done) }, onClose = { vm.close() }, onReopen = vm::reopen)
108
                    } else if (i.locked) {
109
                        Text("Locked: only collaborators may comment.", modifier = Modifier.padding(16.dp))
110
                    }
111
                }
112
            }
113
        }
114
    }
115
    if (assigning) {
116
        AssigneePicker(current = (state as? Load.Ready)?.value?.issue?.assignees.orEmpty(), me = me, onSave = vm::setAssignees, onDone = { assigning = false })
117
    }
118
    if (picking) {
119
        LabelPicker(vm, current = (state as? Load.Ready)?.value?.issue?.labels?.map { it.name }.orEmpty(), onDone = { picking = false })
120
    }
121
}
122
123
@Composable
124
private fun CommentCard(c: Comment) {
125
    ElevatedCard(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp)) {
126
        Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
127
            Text("${c.author} · ${relativeTime(c.createdMs)}", style = MaterialTheme.typography.labelMedium)
128
            Markdown(c.body)
129
        }
130
    }
131
}
132
133
@Composable
134
private fun CommentBox(working: Boolean, isOpen: Boolean, onComment: (String, (Boolean) -> Unit) -> Unit, onClose: () -> Unit, onReopen: () -> Unit) {
135
    var text by rememberSaveable { mutableStateOf("") }
136
    Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
137
        OutlinedTextField(text, { text = it }, label = { Text("Comment (Markdown)") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
138
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
139
            Button(enabled = !working && text.isNotBlank(), onClick = { onComment(text) { ok -> if (ok) text = "" } }) { Text("Comment") }
140
            if (isOpen) {
141
                OutlinedButton(enabled = !working, onClick = onClose) { Text("Close issue") }
142
            } else {
143
                OutlinedButton(enabled = !working, onClick = onReopen) { Text("Reopen") }
144
            }
145
        }
146
    }
147
}
148
149
/** The repository's labels, ticked where the issue has them; saved as the whole set. */
150
@Composable
151
private fun LabelPicker(vm: IssueViewModel, current: List<String>, onDone: () -> Unit) {
152
    val labels by vm.labels.collectAsStateWithLifecycle()
153
    val chosen = remember { mutableStateListOf<String>().apply { addAll(current) } }
154
    LaunchedEffect(Unit) { vm.loadLabels() }
155
    AlertDialog(
156
        onDismissRequest = onDone,
157
        title = { Text("Labels") },
158
        text = {
159
            when (val l = labels) {
160
                is Load.Ready -> LazyColumn {
161
                    items(l.value, key = { it.name }) { label ->
162
                        Row(verticalAlignment = Alignment.CenterVertically) {
163
                            Checkbox(label.name in chosen, onCheckedChange = { on -> if (on) chosen += label.name else chosen -= label.name })
164
                            LabelChip(label)
165
                        }
166
                    }
167
                }
168
                is Load.Failed -> Text(l.message)
169
                Load.Loading -> Text("Loading…")
170
            }
171
        },
172
        confirmButton = {
173
            TextButton(onClick = {
174
                vm.setLabels(chosen.toList())
175
                onDone()
176
            }) { Text("Save") }
177
        },
178
        dismissButton = { TextButton(onClick = onDone) { Text("Cancel") } },
179
    )
180
}
181
182
/**
183
 * Who the issue is assigned to (#4): each removable, you with one tap, and
184
 * anyone by their username; saved as the whole set (triage on the hub).
185
 */
186
@Composable
187
private fun AssigneePicker(current: List<String>, me: String, onSave: (List<String>) -> Unit, onDone: () -> Unit) {
188
    val chosen = remember { mutableStateListOf<String>().apply { addAll(current) } }
189
    var name by remember { mutableStateOf("") }
190
    AlertDialog(
191
        onDismissRequest = onDone,
192
        title = { Text("Assignees") },
193
        text = {
194
            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
195
                if (chosen.isEmpty()) Text("No one yet.", color = MaterialTheme.colorScheme.onSurfaceVariant)
196
                chosen.forEach { who ->
197
                    Row(verticalAlignment = Alignment.CenterVertically) {
198
                        Text("@$who", modifier = Modifier.weight(1f))
199
                        IconButton(onClick = { chosen -= who }) { Icon(Icons.Outlined.Close, contentDescription = "Unassign $who") }
200
                    }
201
                }
202
                if (me.isNotEmpty() && me !in chosen) TextButton(onClick = { chosen += me }) { Text("Assign me") }
203
                OutlinedTextField(
204
                    value = name,
205
                    onValueChange = { name = it.trim() },
206
                    label = { Text("Username") },
207
                    singleLine = true,
208
                    trailingIcon = {
209
                        IconButton(enabled = name.isNotBlank(), onClick = {
210
                            val who = name.removePrefix("@")
211
                            if (who.isNotEmpty() && who !in chosen) chosen += who
212
                            name = ""
213
                        }) { Icon(Icons.Outlined.Add, contentDescription = "Add") }
214
                    },
215
                )
216
            }
217
        },
218
        confirmButton = {
219
            TextButton(onClick = {
220
                onSave(chosen.toList())
221
                onDone()
222
            }) { Text("Save") }
223
        },
224
        dismissButton = { TextButton(onClick = onDone) { Text("Cancel") } },
225
    )
226
}
227
228
/** A new issue's title and description; opened, it shows on its own screen. */
229
@Composable
230
fun NewIssueScreen(vm: NewIssueViewModel, onBack: () -> Unit, onOpened: (Long) -> Unit) {
231
    val working by vm.working.collectAsStateWithLifecycle()
232
    val error by vm.error.collectAsStateWithLifecycle()
233
    var title by rememberSaveable { mutableStateOf("") }
234
    var body by rememberSaveable { mutableStateOf("") }
235
    AppScreen(title = "New issue", subtitle = "${vm.owner}/${vm.name}", onBack = onBack) {
236
        Column(Modifier.padding(16.dp).imePadding(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
237
            OutlinedTextField(title, { title = it }, label = { Text("Title") }, singleLine = true, modifier = Modifier.fillMaxWidth())
238
            OutlinedTextField(body, { body = it }, label = { Text("Description (Markdown)") }, minLines = 6, modifier = Modifier.fillMaxWidth())
239
            error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
240
            Button(enabled = !working && title.isNotBlank(), onClick = { vm.open(title, body) { onOpened(it.number) } }) { Text("Open issue") }
241
        }
242
    }
243
}