OXYGENHUB
# 1b55d7f47d9f oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / dashboard / DashboardScreen.kt
11.2 KiB · last changed The widget's foot: a white mark and title, counts of unread, reviews and assigned issues that open their screens, and the phone's runner job; Assigned to you on the dashboard (closes #31) 4 days ago source blame history raw
1
package io.oxygenhub.android.ui.dashboard
2
3
import androidx.compose.foundation.clickable
4
import androidx.compose.foundation.layout.Arrangement
5
import androidx.compose.foundation.layout.BoxWithConstraints
6
import androidx.compose.foundation.layout.Column
7
import androidx.compose.foundation.layout.Row
8
import androidx.compose.foundation.layout.Spacer
9
import androidx.compose.foundation.layout.fillMaxSize
10
import androidx.compose.foundation.layout.fillMaxWidth
11
import androidx.compose.foundation.layout.height
12
import androidx.compose.foundation.layout.padding
13
import androidx.compose.foundation.rememberScrollState
14
import androidx.compose.foundation.verticalScroll
15
import androidx.compose.material.icons.Icons
16
import androidx.compose.material.icons.automirrored.outlined.CallMerge
17
import androidx.compose.material.icons.outlined.EditNote
18
import androidx.compose.material.icons.outlined.Inbox
19
import androidx.compose.material.icons.outlined.MarkEmailUnread
20
import androidx.compose.material.icons.outlined.RadioButtonUnchecked
21
import androidx.compose.material.icons.outlined.Search
22
import androidx.compose.material.icons.outlined.ViewKanban
23
import androidx.compose.material3.HorizontalDivider
24
import androidx.compose.material3.Icon
25
import androidx.compose.material3.IconButton
26
import androidx.compose.material3.ListItem
27
import androidx.compose.material3.ListItemDefaults
28
import androidx.compose.material3.MaterialTheme
29
import androidx.compose.material3.Text
30
import androidx.compose.material3.TextButton
31
import androidx.compose.runtime.Composable
32
import androidx.compose.runtime.getValue
33
import androidx.compose.ui.Alignment
34
import androidx.compose.ui.Modifier
35
import androidx.compose.ui.graphics.Color
36
import androidx.compose.ui.text.font.FontWeight
37
import androidx.compose.ui.text.style.TextOverflow
38
import androidx.compose.ui.unit.dp
39
import androidx.lifecycle.compose.collectAsStateWithLifecycle
40
import io.oxygenhub.android.api.Inbox
41
import io.oxygenhub.android.api.IssueBrief
42
import io.oxygenhub.android.api.ProposalBrief
43
import io.oxygenhub.android.api.Repository
44
import io.oxygenhub.android.ui.backdrop.Backdrop
45
import io.oxygenhub.android.ui.backdrop.skyScene
46
import io.oxygenhub.android.ui.common.AppScreen
47
import io.oxygenhub.android.ui.common.AvatarImage
48
import io.oxygenhub.android.ui.common.Load
49
import io.oxygenhub.android.ui.common.LoadContent
50
import io.oxygenhub.android.ui.common.Panel
51
import io.oxygenhub.android.ui.common.panelColors
52
import io.oxygenhub.android.ui.common.relativeTime
53
import io.oxygenhub.android.ui.repos.RepositoryRow
54
import io.oxygenhub.android.ui.theme.LocalStatusColors
55
import io.oxygenhub.android.ui.theme.Tones
56
57
@Composable
58
fun DashboardScreen(
59
    vm: DashboardViewModel,
60
    hub: String,
61
    inbox: Load<Inbox>,
62
    onRefreshInbox: () -> Unit,
63
    onOpenRepository: (Repository) -> Unit,
64
    onAllRepositories: () -> Unit,
65
    onInbox: () -> Unit,
66
    onOpenProposal: (ProposalBrief) -> Unit = {},
67
    onOpenIssue: (IssueBrief) -> Unit = {},
68
    onSearch: (() -> Unit)? = null,
69
    /** Projects, when the bar has no room for it (a phone, #14). */
70
    onProjects: (() -> Unit)? = null,
71
) {
72
    val state by vm.state.collectAsStateWithLifecycle()
73
    AppScreen(
74
        title = "Dashboard",
75
        subtitle = hub.substringAfter("://"),
76
        actions = {
77
            if (onProjects != null) {
78
                IconButton(onClick = onProjects) { Icon(Icons.Outlined.ViewKanban, contentDescription = "Projects") }
79
            }
80
            if (onSearch != null) {
81
                IconButton(onClick = onSearch) { Icon(Icons.Outlined.Search, contentDescription = "Search issues and proposals") }
82
            }
83
        },
84
    ) {
85
        LoadContent(state, onRefresh = { vm.refresh(); onRefreshInbox() }) { d ->
86
            // Your sky behind it, as on the hub's dashboard (#23): a finger draws it near and names a repository.
87
            Backdrop(key = d.sky, make = { still -> skyScene(d.sky, still) }, modifier = Modifier.fillMaxSize()) {
88
                DashboardContent(d, inbox, onOpenRepository, onAllRepositories, onInbox, onOpenProposal, onOpenIssue)
89
            }
90
        }
91
    }
92
}
93
94
@Composable
95
fun DashboardContent(
96
    d: Dashboard,
97
    inbox: Load<Inbox>,
98
    onOpenRepository: (Repository) -> Unit,
99
    onAllRepositories: () -> Unit,
100
    onInbox: () -> Unit,
101
    onOpenProposal: (ProposalBrief) -> Unit = {},
102
    onOpenIssue: (IssueBrief) -> Unit = {},
103
) {
104
    BoxWithConstraints {
105
        // Side by side once there is room (a tablet, or a foldable open).
106
        val wide = maxWidth >= 720.dp
107
        Column(
108
            Modifier
109
                .fillMaxWidth()
110
                .verticalScroll(rememberScrollState())
111
                .padding(16.dp),
112
        ) {
113
            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
114
                AvatarImage(d.avatar, d.username, size = 48.dp)
115
                Text(d.greeting, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
116
            }
117
            Spacer(Modifier.height(16.dp))
118
            if (wide) {
119
                Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
120
                    Column(Modifier.weight(2f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
121
                        Proposals(d, onOpenProposal)
122
                        Assigned(d, onOpenIssue)
123
                        Recent(d, onOpenRepository, onAllRepositories)
124
                    }
125
                    Column(Modifier.weight(1f)) { Unread(inbox, onInbox) }
126
                }
127
            } else {
128
                Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
129
                    Unread(inbox, onInbox)
130
                    Proposals(d, onOpenProposal)
131
                    Assigned(d, onOpenIssue)
132
                    Recent(d, onOpenRepository, onAllRepositories)
133
                }
134
            }
135
        }
136
    }
137
}
138
139
/** Proposals waiting for the account's review, then its own: each opens where it can be landed. */
140
@Composable
141
private fun Proposals(d: Dashboard, onOpen: (ProposalBrief) -> Unit) {
142
    val p = d.proposals ?: return
143
    Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
144
        if (p.review.isNotEmpty()) ProposalCard("Waiting for your review", p.review, onOpen)
145
        if (p.authored.isNotEmpty()) ProposalCard("Your proposals", p.authored, onOpen)
146
    }
147
}
148
149
/** Open issues assigned to the account (#31), as the hub's dashboard lists them; each opens. */
150
@Composable
151
private fun Assigned(d: Dashboard, onOpen: (IssueBrief) -> Unit) {
152
    val list = d.assigned?.takeIf { it.isNotEmpty() } ?: return
153
    Panel(Modifier.fillMaxWidth(), colors = panelColors(alpha = 0.9f)) {
154
        Text(
155
            "Assigned to you · ${list.size}",
156
            style = MaterialTheme.typography.titleMedium,
157
            modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 4.dp),
158
        )
159
        list.forEachIndexed { i, b ->
160
            if (i > 0) HorizontalDivider()
161
            ListItem(
162
                modifier = Modifier.clickable { onOpen(b) },
163
                colors = ListItemDefaults.colors(containerColor = Color.Transparent),
164
                leadingContent = { Icon(Icons.Outlined.RadioButtonUnchecked, contentDescription = "Open", tint = LocalStatusColors.current.ok) },
165
                headlineContent = { Text(b.title, maxLines = 2, overflow = TextOverflow.Ellipsis) },
166
                supportingContent = { Text("${b.repository} #${b.number} · ${relativeTime(b.updatedMs)}", maxLines = 1, overflow = TextOverflow.Ellipsis) },
167
            )
168
        }
169
    }
170
}
171
172
@Composable
173
private fun ProposalCard(title: String, list: List<ProposalBrief>, onOpen: (ProposalBrief) -> Unit) {
174
    Panel(Modifier.fillMaxWidth(), colors = panelColors(alpha = 0.9f)) {
175
        Text(
176
            "$title · ${list.size}",
177
            style = MaterialTheme.typography.titleMedium,
178
            modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 4.dp),
179
        )
180
        list.forEachIndexed { i, b ->
181
            if (i > 0) HorizontalDivider()
182
            ListItem(
183
                modifier = Modifier.clickable { onOpen(b) },
184
                colors = ListItemDefaults.colors(containerColor = Color.Transparent),
185
                leadingContent = {
186
                    Icon(
187
                        if (b.state == "draft") Icons.Outlined.EditNote else Icons.AutoMirrored.Outlined.CallMerge,
188
                        contentDescription = b.state,
189
                        tint = if (b.state == "draft") MaterialTheme.colorScheme.onSurfaceVariant else LocalStatusColors.current.ok,
190
                    )
191
                },
192
                headlineContent = { Text(b.title, maxLines = 2, overflow = TextOverflow.Ellipsis) },
193
                supportingContent = {
194
                    val by = if (title.startsWith("Waiting")) " by ${b.author}" else ""
195
                    val conflicts = if (b.conflicted) " · conflicts" else ""
196
                    Text("${b.repository} #${b.number}$by · ${relativeTime(b.updatedMs)}$conflicts", maxLines = 1, overflow = TextOverflow.Ellipsis)
197
                },
198
            )
199
        }
200
    }
201
}
202
203
@Composable
204
private fun Unread(inbox: Load<Inbox>, onInbox: () -> Unit) {
205
    Panel(Modifier.fillMaxWidth().clickable(onClick = onInbox), colors = panelColors(alpha = 0.9f)) {
206
        ListItem(
207
            colors = ListItemDefaults.colors(containerColor = Color.Transparent),
208
            leadingContent = {
209
                Icon(if ((inbox.valueOrNull?.unread ?: 0) > 0) Icons.Outlined.MarkEmailUnread else Icons.Outlined.Inbox, contentDescription = null, tint = Tones.info)
210
            },
211
            headlineContent = {
212
                Text(
213
                    when (inbox) {
214
                        Load.Loading -> "Inbox"
215
                        is Load.Failed -> "Inbox unavailable"
216
                        is Load.Ready -> when (val n = inbox.value.unread) {
217
                            0L -> "Nothing unread"
218
                            1L -> "1 unread notification"
219
                            else -> "$n unread notifications"
220
                        }
221
                    },
222
                )
223
            },
224
            supportingContent = (inbox as? Load.Failed)?.let { f -> { Text(f.message) } },
225
        )
226
    }
227
}
228
229
@Composable
230
private fun Recent(d: Dashboard, onOpenRepository: (Repository) -> Unit, onAllRepositories: () -> Unit) {
231
    Panel(Modifier.fillMaxWidth(), colors = panelColors(alpha = 0.9f)) {
232
        Text(
233
            if (d.recentAreYours) "Your recent repositories" else "Recently updated on the hub",
234
            style = MaterialTheme.typography.titleMedium,
235
            modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp),
236
        )
237
        if (!d.recentAreYours) {
238
            Text(
239
                "You have no repositories of your own yet.",
240
                style = MaterialTheme.typography.bodySmall,
241
                color = MaterialTheme.colorScheme.onSurfaceVariant,
242
                modifier = Modifier.padding(horizontal = 16.dp),
243
            )
244
        }
245
        if (d.recent.isEmpty()) {
246
            Text("No repositories to show.", modifier = Modifier.padding(16.dp))
247
        }
248
        d.recent.forEachIndexed { i, repo ->
249
            if (i > 0) HorizontalDivider()
250
            RepositoryRow(repo, onClick = { onOpenRepository(repo) })
251
        }
252
        TextButton(onClick = onAllRepositories, modifier = Modifier.padding(8.dp)) { Text("All repositories") }
253
    }
254
}