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