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