OXYGENHUB
# 31e4f3755627 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / issues / IssueScreen.kt
9.1 KiB · last changed Issues on the phone: read, comment, close and reopen, labels, and new issues (refs #4) 10 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.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
}