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