| 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 |
} |