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