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