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