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