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