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