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