OXYGENHUB
◆ v0.1.5 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / issues / IssuesScreen.kt
6.6 KiB · last changed Colour on icons: what each stands for, in the hub's colours (closes #25) 7 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.ChatBubbleOutline
18
import androidx.compose.material.icons.outlined.CheckCircle
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
import io.oxygenhub.android.ui.theme.Tones
48
49
/**
50
 * A repository's issues with their filter, for the repository's tab; an
51
 * issue opens on its own screen (#4), and [onNewIssue] starts one.
52
 */
53
@Composable
54
fun IssueList(vm: IssuesViewModel, onOpenIssue: (Long) -> Unit, onNewIssue: (() -> Unit)?) {
55
    val state by vm.state.collectAsStateWithLifecycle()
56
    val filter by vm.filter.collectAsStateWithLifecycle()
57
    Column(Modifier.fillMaxSize()) {
58
        Row(
59
            Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp),
60
            horizontalArrangement = Arrangement.spacedBy(8.dp),
61
            verticalAlignment = Alignment.CenterVertically,
62
        ) {
63
            IssueState.entries.forEach { s ->
64
                FilterChip(selected = filter == s, onClick = { vm.setFilter(s) }, label = { Text(s.name) })
65
            }
66
            if (onNewIssue != null) {
67
                TextButton(onClick = onNewIssue) { Text("New issue") }
68
            }
69
        }
70
        LoadContent(state, onRefresh = vm::refresh) { issues ->
71
            if (issues.isEmpty()) {
72
                EmptyState(
73
                    Icons.Outlined.TaskAlt,
74
                    when (filter) {
75
                        IssueState.Open -> "No open issues"
76
                        IssueState.Closed -> "No closed issues"
77
                        IssueState.All -> "No issues yet"
78
                    },
79
                    "Issues for ${vm.owner}/${vm.name} show here.",
80
                )
81
            } else {
82
                LazyColumn(Modifier.fillMaxSize()) {
83
                    items(issues, key = { it.number }) { issue ->
84
                        IssueRow(issue, onClick = { onOpenIssue(issue.number) })
85
                        HorizontalDivider()
86
                    }
87
                }
88
            }
89
        }
90
    }
91
}
92
93
@Composable
94
fun IssueRow(issue: Issue, onClick: () -> Unit) {
95
    val status = LocalStatusColors.current
96
    ListItem(
97
        modifier = Modifier.clickable(onClick = onClick),
98
        leadingContent = {
99
            if (issue.isOpen) {
100
                Icon(Icons.Outlined.RadioButtonUnchecked, contentDescription = "Open", tint = status.ok)
101
            } else {
102
                Icon(Icons.Outlined.CheckCircle, contentDescription = "Closed", tint = status.warn)
103
            }
104
        },
105
        overlineContent = if (issue.pinned) {
106
            {
107
                Row(verticalAlignment = Alignment.CenterVertically) {
108
                    Icon(Icons.Outlined.PushPin, contentDescription = null, modifier = Modifier.padding(end = 4.dp), tint = Tones.info)
109
                    Text("Pinned")
110
                }
111
            }
112
        } else {
113
            null
114
        },
115
        headlineContent = { Text(issue.title, maxLines = 2, overflow = TextOverflow.Ellipsis) },
116
        supportingContent = {
117
            Column {
118
                val verb = if (issue.isOpen) "opened" else "closed"
119
                val at = if (issue.isOpen) issue.createdMs else (issue.closedMs ?: issue.updatedMs)
120
                Text("#${issue.number} $verb ${relativeTime(at)} by ${issue.author}", maxLines = 1, overflow = TextOverflow.Ellipsis)
121
                if (issue.labels.isNotEmpty()) {
122
                    FlowRow(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
123
                        issue.labels.forEach { LabelChip(it) }
124
                    }
125
                }
126
            }
127
        },
128
        trailingContent = if (issue.comments > 0) {
129
            {
130
                Row(verticalAlignment = Alignment.CenterVertically) {
131
                    Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Comments", modifier = Modifier.padding(end = 4.dp))
132
                    Text(issue.comments.toString())
133
                }
134
            }
135
        } else {
136
            null
137
        },
138
    )
139
}
140
141
@Composable
142
internal fun LabelChip(label: Label) {
143
    val colour = parseColour(label.colour) ?: MaterialTheme.colorScheme.secondaryContainer
144
    val ink = if (colour.luminance() > 0.5f) Color.Black else Color.White
145
    Text(
146
        label.name,
147
        color = ink,
148
        fontSize = 12.sp,
149
        modifier = Modifier
150
            .clip(RoundedCornerShape(50))
151
            .background(colour)
152
            .padding(horizontal = 8.dp, vertical = 2.dp),
153
    )
154
}
155
156
/** `#rrggbb` (or `rrggbb`) to a colour; null for anything else. */
157
internal fun parseColour(s: String): Color? {
158
    val hex = s.trim().removePrefix("#")
159
    if (hex.length != 6) return null
160
    val v = hex.toLongOrNull(16) ?: return null
161
    return Color(0xFF000000 or v)
162
}