OXYGENHUB
# 1823bc12eda1 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / issues / IssuesScreen.kt
6.6 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.background
4
import androidx.compose.foundation.clickable
5
import androidx.compose.foundation.horizontalScroll
6
import androidx.compose.foundation.layout.Arrangement
7
import androidx.compose.foundation.layout.Column
8
import androidx.compose.foundation.layout.FlowRow
9
import androidx.compose.foundation.layout.Row
10
import androidx.compose.foundation.layout.fillMaxSize
11
import androidx.compose.foundation.layout.padding
12
import androidx.compose.foundation.lazy.LazyColumn
13
import androidx.compose.foundation.lazy.items
14
import androidx.compose.foundation.rememberScrollState
15
import androidx.compose.foundation.shape.RoundedCornerShape
16
import androidx.compose.material.icons.Icons
17
import androidx.compose.material.icons.outlined.CheckCircle
18
import androidx.compose.material.icons.outlined.ChatBubbleOutline
19
import androidx.compose.material.icons.outlined.PushPin
20
import androidx.compose.material.icons.outlined.RadioButtonUnchecked
21
import androidx.compose.material.icons.outlined.TaskAlt
22
import androidx.compose.material3.FilterChip
23
import androidx.compose.material3.HorizontalDivider
24
import androidx.compose.material3.Icon
25
import androidx.compose.material3.ListItem
26
import androidx.compose.material3.MaterialTheme
27
import androidx.compose.material3.Text
28
import androidx.compose.material3.TextButton
29
import androidx.compose.runtime.Composable
30
import androidx.compose.runtime.getValue
31
import androidx.compose.ui.Alignment
32
import androidx.compose.ui.Modifier
33
import androidx.compose.ui.draw.clip
34
import androidx.compose.ui.graphics.Color
35
import androidx.compose.ui.graphics.luminance
36
import androidx.compose.ui.text.style.TextOverflow
37
import androidx.compose.ui.unit.dp
38
import androidx.compose.ui.unit.sp
39
import androidx.lifecycle.compose.collectAsStateWithLifecycle
40
import io.oxygenhub.android.api.Issue
41
import io.oxygenhub.android.api.IssueState
42
import io.oxygenhub.android.api.Label
43
import io.oxygenhub.android.ui.common.EmptyState
44
import io.oxygenhub.android.ui.common.LoadContent
45
import io.oxygenhub.android.ui.common.relativeTime
46
import io.oxygenhub.android.ui.theme.LocalStatusColors
47
48
/**
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
 */
52
@Composable
53
fun IssueList(vm: IssuesViewModel, onOpenIssue: (Long) -> Unit, onNewIssue: (() -> Unit)?) {
54
    val state by vm.state.collectAsStateWithLifecycle()
55
    val filter by vm.filter.collectAsStateWithLifecycle()
56
    Column(Modifier.fillMaxSize()) {
57
        Row(
58
            Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp),
59
            horizontalArrangement = Arrangement.spacedBy(8.dp),
60
            verticalAlignment = Alignment.CenterVertically,
61
        ) {
62
            IssueState.entries.forEach { s ->
63
                FilterChip(selected = filter == s, onClick = { vm.setFilter(s) }, label = { Text(s.name) })
64
            }
65
            if (onNewIssue != null) {
66
                TextButton(onClick = onNewIssue) { Text("New issue") }
67
            }
68
        }
69
        LoadContent(state, onRefresh = vm::refresh) { issues ->
70
            if (issues.isEmpty()) {
71
                EmptyState(
72
                    Icons.Outlined.TaskAlt,
73
                    when (filter) {
74
                        IssueState.Open -> "No open issues"
75
                        IssueState.Closed -> "No closed issues"
76
                        IssueState.All -> "No issues yet"
77
                    },
78
                    "Issues for ${vm.owner}/${vm.name} show here.",
79
                )
80
            } else {
81
                LazyColumn(Modifier.fillMaxSize()) {
82
                    items(issues, key = { it.number }) { issue ->
83
                        IssueRow(issue, onClick = { onOpenIssue(issue.number) })
84
                        HorizontalDivider()
85
                    }
86
                }
87
            }
88
        }
89
    }
90
}
91
92
@Composable
93
fun IssueRow(issue: Issue, onClick: () -> Unit) {
94
    val status = LocalStatusColors.current
95
    ListItem(
96
        modifier = Modifier.clickable(onClick = onClick),
97
        leadingContent = {
98
            if (issue.isOpen) {
99
                Icon(Icons.Outlined.RadioButtonUnchecked, contentDescription = "Open", tint = status.ok)
100
            } else {
101
                Icon(Icons.Outlined.CheckCircle, contentDescription = "Closed", tint = status.warn)
102
            }
103
        },
104
        overlineContent = if (issue.pinned) {
105
            {
106
                Row(verticalAlignment = Alignment.CenterVertically) {
107
                    Icon(Icons.Outlined.PushPin, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
108
                    Text("Pinned")
109
                }
110
            }
111
        } else {
112
            null
113
        },
114
        headlineContent = { Text(issue.title, maxLines = 2, overflow = TextOverflow.Ellipsis) },
115
        supportingContent = {
116
            Column {
117
                val verb = if (issue.isOpen) "opened" else "closed"
118
                val at = if (issue.isOpen) issue.createdMs else (issue.closedMs ?: issue.updatedMs)
119
                Text("#${issue.number} $verb ${relativeTime(at)} by ${issue.author}", maxLines = 1, overflow = TextOverflow.Ellipsis)
120
                if (issue.labels.isNotEmpty()) {
121
                    FlowRow(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
122
                        issue.labels.forEach { LabelChip(it) }
123
                    }
124
                }
125
            }
126
        },
127
        trailingContent = if (issue.comments > 0) {
128
            {
129
                Row(verticalAlignment = Alignment.CenterVertically) {
130
                    Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Comments", modifier = Modifier.padding(end = 4.dp))
131
                    Text(issue.comments.toString())
132
                }
133
            }
134
        } else {
135
            null
136
        },
137
    )
138
}
139
140
@Composable
141
internal fun LabelChip(label: Label) {
142
    val colour = parseColour(label.colour) ?: MaterialTheme.colorScheme.secondaryContainer
143
    val ink = if (colour.luminance() > 0.5f) Color.Black else Color.White
144
    Text(
145
        label.name,
146
        color = ink,
147
        fontSize = 12.sp,
148
        modifier = Modifier
149
            .clip(RoundedCornerShape(50))
150
            .background(colour)
151
            .padding(horizontal = 8.dp, vertical = 2.dp),
152
    )
153
}
154
155
/** `#rrggbb` (or `rrggbb`) to a colour; null for anything else. */
156
internal fun parseColour(s: String): Color? {
157
    val hex = s.trim().removePrefix("#")
158
    if (hex.length != 6) return null
159
    val v = hex.toLongOrNull(16) ?: return null
160
    return Color(0xFF000000 or v)
161
}