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