OXYGENHUB
# e4b11612e7de oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / projects / ProjectsScreen.kt
7.8 KiB · last changed Panels you can see: the hub's palette by default, clear surface steps and a 1 dp edge on every panel (closes #24) 7 days ago source blame history raw
1
package io.oxygenhub.android.ui.projects
2
3
import androidx.compose.foundation.ExperimentalFoundationApi
4
import androidx.compose.foundation.clickable
5
import androidx.compose.foundation.combinedClickable
6
import androidx.compose.foundation.layout.Arrangement
7
import androidx.compose.foundation.layout.Box
8
import androidx.compose.foundation.layout.Column
9
import androidx.compose.foundation.layout.ExperimentalLayoutApi
10
import androidx.compose.foundation.layout.FlowRow
11
import androidx.compose.foundation.layout.fillMaxSize
12
import androidx.compose.foundation.layout.fillMaxWidth
13
import androidx.compose.foundation.layout.padding
14
import androidx.compose.foundation.lazy.LazyColumn
15
import androidx.compose.foundation.lazy.items
16
import androidx.compose.foundation.pager.HorizontalPager
17
import androidx.compose.foundation.pager.rememberPagerState
18
import androidx.compose.material.icons.Icons
19
import androidx.compose.material.icons.outlined.ViewKanban
20
import androidx.compose.material3.DropdownMenu
21
import androidx.compose.material3.DropdownMenuItem
22
import androidx.compose.material3.HorizontalDivider
23
import androidx.compose.material3.ListItem
24
import androidx.compose.material3.MaterialTheme
25
import androidx.compose.material3.PrimaryScrollableTabRow
26
import androidx.compose.material3.SuggestionChip
27
import androidx.compose.material3.Tab
28
import androidx.compose.material3.Text
29
import androidx.compose.runtime.Composable
30
import androidx.compose.runtime.getValue
31
import androidx.compose.runtime.mutableStateOf
32
import androidx.compose.runtime.remember
33
import androidx.compose.runtime.rememberCoroutineScope
34
import androidx.compose.runtime.setValue
35
import androidx.compose.ui.Modifier
36
import androidx.compose.ui.unit.dp
37
import androidx.lifecycle.compose.collectAsStateWithLifecycle
38
import io.oxygenhub.android.api.Project
39
import io.oxygenhub.android.api.ProjectCard
40
import io.oxygenhub.android.ui.common.AppScreen
41
import io.oxygenhub.android.ui.common.EmptyState
42
import io.oxygenhub.android.ui.common.LoadContent
43
import io.oxygenhub.android.ui.common.Panel
44
import io.oxygenhub.android.ui.common.relativeTime
45
import io.oxygenhub.android.ui.theme.LocalStatusColors
46
import kotlinx.coroutines.launch
47
48
/** The boards the account can see, each opening its columns. */
49
@Composable
50
fun ProjectsScreen(vm: ProjectsViewModel, onOpen: (Project) -> Unit) {
51
    val state by vm.state.collectAsStateWithLifecycle()
52
    AppScreen(title = "Projects") {
53
        LoadContent(state, onRefresh = vm::refresh) { projects ->
54
            if (projects.isEmpty()) {
55
                EmptyState(Icons.Outlined.ViewKanban, "No boards yet", "Project boards you and your organisations keep show here.")
56
            } else {
57
                LazyColumn {
58
                    items(projects, key = { "${it.owner}/${it.number}" }) { p ->
59
                        ListItem(
60
                            modifier = Modifier.clickable { onOpen(p) },
61
                            headlineContent = { Text(p.title) },
62
                            supportingContent = {
63
                                Text(
64
                                    listOfNotNull(
65
                                        "${p.owner} · #${p.number}",
66
                                        "closed".takeIf { p.closed },
67
                                        "private".takeIf { !p.public },
68
                                        relativeTime(p.updatedMs).takeIf { it.isNotEmpty() },
69
                                    ).joinToString(" · "),
70
                                )
71
                            },
72
                        )
73
                        HorizontalDivider()
74
                    }
75
                }
76
            }
77
        }
78
    }
79
}
80
81
/**
82
 * A board: its columns as tabs to swipe between, each column's cards; a
83
 * card opens its issue or proposal, and a long press moves it.
84
 */
85
@Composable
86
fun BoardScreen(vm: BoardViewModel, canWrite: Boolean, onBack: () -> Unit, onOpenCard: (ProjectCard) -> Unit) {
87
    val state by vm.state.collectAsStateWithLifecycle()
88
    val error by vm.error.collectAsStateWithLifecycle()
89
    AppScreen(
90
        title = (state as? io.oxygenhub.android.ui.common.Load.Ready)?.value?.project?.title ?: "Board",
91
        subtitle = "${vm.owner} · #${vm.number}",
92
        onBack = onBack,
93
    ) {
94
        LoadContent(state, onRefresh = vm::refresh) { board ->
95
            val pages = rememberPagerState { board.columns.size }
96
            val scope = rememberCoroutineScope()
97
            Column(Modifier.fillMaxSize()) {
98
                if (board.columns.isNotEmpty()) {
99
                    PrimaryScrollableTabRow(selectedTabIndex = pages.currentPage.coerceAtMost(board.columns.size - 1)) {
100
                        board.columns.forEachIndexed { i, c ->
101
                            Tab(selected = pages.currentPage == i, onClick = { scope.launch { pages.animateScrollToPage(i) } }, text = { Text("${c.name} ${c.cards.size}") })
102
                        }
103
                    }
104
                }
105
                error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(12.dp)) }
106
                HorizontalPager(pages, Modifier.fillMaxSize()) { page ->
107
                    val column = board.columns[page]
108
                    if (column.cards.isEmpty()) {
109
                        EmptyState(Icons.Outlined.ViewKanban, "Nothing in ${column.name}", "Cards moved here show up here.")
110
                    } else {
111
                        LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
112
                            items(column.cards, key = { it.id }) { card ->
113
                                CardView(card, columns = board.columns.map { it.name }.filter { it != column.name }, canWrite, onOpen = { onOpenCard(card) }, onMove = { vm.move(card.id, it) })
114
                            }
115
                        }
116
                    }
117
                }
118
            }
119
        }
120
    }
121
}
122
123
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
124
@Composable
125
private fun CardView(card: ProjectCard, columns: List<String>, canWrite: Boolean, onOpen: () -> Unit, onMove: (String) -> Unit) {
126
    var menu by remember { mutableStateOf(false) }
127
    val status = LocalStatusColors.current
128
    Box {
129
        Panel(
130
            Modifier.fillMaxWidth().padding(horizontal = 12.dp).combinedClickable(
131
                onClick = { if (card.kind != "note") onOpen() },
132
                onLongClick = { if (canWrite && columns.isNotEmpty()) menu = true },
133
            ),
134
        ) {
135
            Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
136
                if (card.reference.isNotEmpty()) {
137
                    Text(
138
                        "${card.reference} · ${card.state}",
139
                        style = MaterialTheme.typography.labelSmall,
140
                        color = when (card.state) {
141
                            "open" -> status.ok
142
                            "landed", "closed" -> status.warn
143
                            else -> MaterialTheme.colorScheme.onSurfaceVariant
144
                        },
145
                    )
146
                }
147
                Text(card.title, style = MaterialTheme.typography.bodyMedium)
148
                if (card.labels.isNotEmpty() || card.assignees.isNotEmpty()) {
149
                    FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
150
                        card.labels.forEach { SuggestionChip(onClick = {}, label = { Text(it, style = MaterialTheme.typography.labelSmall) }) }
151
                        card.assignees.forEach { Text("@$it", style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(4.dp)) }
152
                    }
153
                }
154
            }
155
        }
156
        DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
157
            columns.forEach { c ->
158
                DropdownMenuItem(text = { Text("Move to $c") }, onClick = {
159
                    menu = false
160
                    onMove(c)
161
                })
162
            }
163
        }
164
    }
165
}