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