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