OXYGENHUB
# 645469f5227b oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / releases / Releases.kt
8.2 KiB · last changed Colour on icons: what each stands for, in the hub's colours (closes #25) 7 days ago source blame history raw
1
package io.oxygenhub.android.ui.releases
2
3
import androidx.compose.foundation.clickable
4
import androidx.compose.foundation.layout.Arrangement
5
import androidx.compose.foundation.layout.Column
6
import androidx.compose.foundation.layout.fillMaxSize
7
import androidx.compose.foundation.layout.fillMaxWidth
8
import androidx.compose.foundation.layout.padding
9
import androidx.compose.foundation.lazy.LazyColumn
10
import androidx.compose.foundation.lazy.items
11
import androidx.compose.foundation.text.KeyboardActions
12
import androidx.compose.foundation.text.KeyboardOptions
13
import androidx.compose.material.icons.Icons
14
import androidx.compose.material.icons.outlined.Download
15
import androidx.compose.material.icons.outlined.NewReleases
16
import androidx.compose.material.icons.outlined.Search
17
import androidx.compose.material3.HorizontalDivider
18
import androidx.compose.material3.Icon
19
import androidx.compose.material3.ListItem
20
import androidx.compose.material3.MaterialTheme
21
import androidx.compose.material3.OutlinedTextField
22
import androidx.compose.material3.Text
23
import androidx.compose.runtime.Composable
24
import androidx.compose.runtime.getValue
25
import androidx.compose.runtime.mutableStateOf
26
import androidx.compose.runtime.saveable.rememberSaveable
27
import androidx.compose.runtime.setValue
28
import androidx.compose.ui.Modifier
29
import androidx.compose.ui.platform.LocalUriHandler
30
import androidx.compose.ui.text.input.ImeAction
31
import androidx.compose.ui.unit.dp
32
import androidx.lifecycle.ViewModel
33
import androidx.lifecycle.compose.collectAsStateWithLifecycle
34
import androidx.lifecycle.viewModelScope
35
import com.mikepenz.markdown.m3.Markdown
36
import io.oxygenhub.android.api.HubApi
37
import io.oxygenhub.android.api.Release
38
import io.oxygenhub.android.api.SearchHit
39
import io.oxygenhub.android.ui.code.bytes
40
import io.oxygenhub.android.ui.common.AppScreen
41
import io.oxygenhub.android.ui.common.EmptyState
42
import io.oxygenhub.android.ui.common.Load
43
import io.oxygenhub.android.ui.common.LoadContent
44
import io.oxygenhub.android.ui.common.NoImages
45
import io.oxygenhub.android.ui.common.Panel
46
import io.oxygenhub.android.ui.common.load
47
import io.oxygenhub.android.ui.common.relativeTime
48
import io.oxygenhub.android.ui.theme.Tones
49
import kotlinx.coroutines.CancellationException
50
import kotlinx.coroutines.Job
51
import kotlinx.coroutines.flow.MutableStateFlow
52
import kotlinx.coroutines.flow.StateFlow
53
import kotlinx.coroutines.flow.asStateFlow
54
import kotlinx.coroutines.launch
55
56
/** A repository's releases, newest first. */
57
class ReleasesViewModel(private val api: HubApi, val owner: String, val name: String) : ViewModel() {
58
    private val _state = MutableStateFlow<Load<List<Release>>>(Load.Loading)
59
    val state: StateFlow<Load<List<Release>>> = _state.asStateFlow()
60
    private var job: Job? = null
61
62
    init {
63
        refresh()
64
    }
65
66
    fun refresh() {
67
        job = load(_state, job) { api.releases(owner, name).sortedByDescending { it.createdMs } }
68
    }
69
}
70
71
/** Issues and proposals found across the account's repositories. */
72
class SearchViewModel(private val api: HubApi) : ViewModel() {
73
    private val _state = MutableStateFlow<Load<List<SearchHit>>?>(null)
74
75
    /** Null before the first search. */
76
    val state: StateFlow<Load<List<SearchHit>>?> = _state.asStateFlow()
77
    private var job: Job? = null
78
79
    fun search(query: String) {
80
        val q = query.trim()
81
        if (q.isEmpty()) return
82
        job?.cancel()
83
        _state.value = Load.Loading
84
        job = viewModelScope.launch {
85
            _state.value = try {
86
                Load.Ready(api.search(q))
87
            } catch (e: CancellationException) {
88
                throw e
89
            } catch (e: Exception) {
90
                Load.failed(e)
91
            }
92
        }
93
    }
94
}
95
96
/** The releases tab: each release's notes rendered and its files to download. */
97
@Composable
98
fun ReleaseList(vm: ReleasesViewModel) {
99
    val state by vm.state.collectAsStateWithLifecycle()
100
    val uri = LocalUriHandler.current
101
    LoadContent(state, onRefresh = vm::refresh) { releases ->
102
        if (releases.isEmpty()) {
103
            EmptyState(Icons.Outlined.NewReleases, "No releases", "Tagged releases of ${vm.owner}/${vm.name} show here.")
104
        } else {
105
            LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
106
                items(releases, key = { it.tag }) { r ->
107
                    Panel(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) {
108
                        Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
109
                            Text(r.title.ifEmpty { r.tag }, style = MaterialTheme.typography.titleMedium)
110
                            Text(
111
                                listOfNotNull(
112
                                    r.tag,
113
                                    "latest".takeIf { r.latest },
114
                                    "pre-release".takeIf { r.prerelease },
115
                                    r.signer?.let { "signed by $it" },
116
                                    relativeTime(r.createdMs).takeIf { it.isNotEmpty() },
117
                                ).joinToString(" · "),
118
                                style = MaterialTheme.typography.labelMedium,
119
                                color = MaterialTheme.colorScheme.onSurfaceVariant,
120
                            )
121
                            if (r.notes.isNotBlank()) Markdown(r.notes, imageTransformer = NoImages)
122
                            r.assets.forEach { a ->
123
                                ListItem(
124
                                    modifier = Modifier.clickable { if (a.url.isNotBlank()) uri.openUri(a.url) },
125
                                    leadingContent = { Icon(Icons.Outlined.Download, contentDescription = "Download", tint = Tones.release) },
126
                                    headlineContent = { Text(a.name) },
127
                                    supportingContent = { Text("${bytes(a.size)} · ${a.downloads} downloads") },
128
                                )
129
                            }
130
                        }
131
                    }
132
                }
133
            }
134
        }
135
    }
136
}
137
138
/** Search issues and proposals; a result opens on its own screen. */
139
@Composable
140
fun SearchScreen(vm: SearchViewModel, onBack: () -> Unit, onOpen: (SearchHit) -> Unit) {
141
    val state by vm.state.collectAsStateWithLifecycle()
142
    var query by rememberSaveable { mutableStateOf("") }
143
    AppScreen(title = "Search", onBack = onBack) {
144
        Column(Modifier.fillMaxSize()) {
145
            OutlinedTextField(
146
                query,
147
                { query = it },
148
                label = { Text("Issues and proposals") },
149
                singleLine = true,
150
                leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) },
151
                keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
152
                keyboardActions = KeyboardActions(onSearch = { vm.search(query) }),
153
                modifier = Modifier.fillMaxWidth().padding(12.dp),
154
            )
155
            when (val s = state) {
156
                null -> EmptyState(Icons.Outlined.Search, "Search", "Words from titles, bodies and comments, in every repository you can read.")
157
                else -> LoadContent(s, onRefresh = { vm.search(query) }) { hits ->
158
                    if (hits.isEmpty()) {
159
                        EmptyState(Icons.Outlined.Search, "Nothing found", "No issue or proposal matches \"$query\".")
160
                    } else {
161
                        LazyColumn {
162
                            items(hits, key = { "${it.repository}#${it.number}" }) { h ->
163
                                ListItem(
164
                                    modifier = Modifier.clickable { onOpen(h) },
165
                                    overlineContent = { Text("${h.repository} · ${h.kind} #${h.number}") },
166
                                    headlineContent = { Text(h.title) },
167
                                    supportingContent = if (h.snippet.isNotBlank()) {
168
                                        { Text(h.snippet, maxLines = 2) }
169
                                    } else {
170
                                        null
171
                                    },
172
                                )
173
                                HorizontalDivider()
174
                            }
175
                        }
176
                    }
177
                }
178
            }
179
        }
180
    }
181
}