OXYGENHUB
# db00632a61d6 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / issues / IssuesScreen.kt
6.8 KiB · last changed Sign-in, dashboard, repositories, issues and inbox screens, adaptive (refs #2) 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.AppScreen
44
import io.oxygenhub.android.ui.common.EmptyState
45
import io.oxygenhub.android.ui.common.LoadContent
46
import io.oxygenhub.android.ui.common.relativeTime
47
import io.oxygenhub.android.ui.theme.LocalStatusColors
48
49
/**
50
 * A repository's issues. For now this is the repository's whole detail
51
 * pane; the repository overview (oxygenhub-android#3) will hold it as one
52
 * of its tabs. An issue opens on the hub's web page until #4 brings the
53
 * issue screen.
54
 */
55
@Composable
56
fun IssuesScreen(vm: IssuesViewModel, onBack: (() -> Unit)?) {
57
    val state by vm.state.collectAsStateWithLifecycle()
58
    val filter by vm.filter.collectAsStateWithLifecycle()
59
    val uri = LocalUriHandler.current
60
    AppScreen(title = "${vm.owner}/${vm.name}", subtitle = "Issues", onBack = onBack) {
61
        Column(Modifier.fillMaxSize()) {
62
            Row(
63
                Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp),
64
                horizontalArrangement = Arrangement.spacedBy(8.dp),
65
            ) {
66
                IssueState.entries.forEach { s ->
67
                    FilterChip(selected = filter == s, onClick = { vm.setFilter(s) }, label = { Text(s.name) })
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 = { if (issue.url.isNotBlank()) uri.openUri(issue.url) })
85
                            HorizontalDivider()
86
                        }
87
                    }
88
                }
89
            }
90
        }
91
    }
92
}
93
94
@Composable
95
fun IssueRow(issue: Issue, onClick: () -> Unit) {
96
    val status = LocalStatusColors.current
97
    ListItem(
98
        modifier = Modifier.clickable(onClick = onClick),
99
        leadingContent = {
100
            if (issue.isOpen) {
101
                Icon(Icons.Outlined.RadioButtonUnchecked, contentDescription = "Open", tint = status.ok)
102
            } else {
103
                Icon(Icons.Outlined.CheckCircle, contentDescription = "Closed", tint = status.warn)
104
            }
105
        },
106
        overlineContent = if (issue.pinned) {
107
            {
108
                Row(verticalAlignment = Alignment.CenterVertically) {
109
                    Icon(Icons.Outlined.PushPin, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
110
                    Text("Pinned")
111
                }
112
            }
113
        } else {
114
            null
115
        },
116
        headlineContent = { Text(issue.title, maxLines = 2, overflow = TextOverflow.Ellipsis) },
117
        supportingContent = {
118
            Column {
119
                val verb = if (issue.isOpen) "opened" else "closed"
120
                val at = if (issue.isOpen) issue.createdMs else (issue.closedMs ?: issue.updatedMs)
121
                Text("#${issue.number} $verb ${relativeTime(at)} by ${issue.author}", maxLines = 1, overflow = TextOverflow.Ellipsis)
122
                if (issue.labels.isNotEmpty()) {
123
                    FlowRow(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
124
                        issue.labels.forEach { LabelChip(it) }
125
                    }
126
                }
127
            }
128
        },
129
        trailingContent = if (issue.comments > 0) {
130
            {
131
                Row(verticalAlignment = Alignment.CenterVertically) {
132
                    Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Comments", modifier = Modifier.padding(end = 4.dp))
133
                    Text(issue.comments.toString())
134
                }
135
            }
136
        } else {
137
            null
138
        },
139
    )
140
}
141
142
@Composable
143
private fun LabelChip(label: Label) {
144
    val colour = parseColour(label.colour) ?: MaterialTheme.colorScheme.secondaryContainer
145
    val ink = if (colour.luminance() > 0.5f) Color.Black else Color.White
146
    Text(
147
        label.name,
148
        color = ink,
149
        fontSize = 12.sp,
150
        modifier = Modifier
151
            .clip(RoundedCornerShape(50))
152
            .background(colour)
153
            .padding(horizontal = 8.dp, vertical = 2.dp),
154
    )
155
}
156
157
/** `#rrggbb` (or `rrggbb`) to a colour; null for anything else. */
158
internal fun parseColour(s: String): Color? {
159
    val hex = s.trim().removePrefix("#")
160
    if (hex.length != 6) return null
161
    val v = hex.toLongOrNull(16) ?: return null
162
    return Color(0xFF000000 or v)
163
}