⭐
Log in to mark this repository.
Android: keep search results across opening one and coming back
6f01817 · Gabriel Morell · 2026-09-26 04:14
Message
{commit_body(@commit)}
Files changed
modified
android/app/src/main/java/org/neiam/dms/app/ui/SearchScreen.kt
+28
−110
@@ -10,12 +10,9 @@ import androidx.compose.runtime.*
| 10 | 10 | import androidx.compose.ui.Alignment |
| 11 | 11 | import androidx.compose.ui.Modifier |
| 12 | 12 | import androidx.compose.ui.unit.dp |
| 13 | −import kotlinx.coroutines.launch | |
| 14 | −import org.neiam.dms.app.data.DmsApi | |
| 15 | −import org.neiam.dms.app.data.DmsClient | |
| 13 | +import androidx.lifecycle.viewmodel.compose.viewModel | |
| 16 | 14 | import org.neiam.dms.app.data.SearchSources |
| 17 | 15 | import org.neiam.dms.app.data.TokenStore |
| 18 | −import org.neiam.dms.app.data.UploadSummary | |
| 19 | 16 | |
| 20 | 17 | /** |
| 21 | 18 | * Full-fidelity search. Hits `/api/search` (server-side parity with the web |
@@ -27,6 +24,9 @@ import org.neiam.dms.app.data.UploadSummary
| 27 | 24 | * Results carry `matched_tags` populated by the server, which the |
| 28 | 25 | * UploadCard renders as suggestion chips under each thumbnail so you can |
| 29 | 26 | * see which tag triggered the hit. |
| 27 | + * | |
| 28 | + * State lives in [SearchViewModel] so it survives opening a result and | |
| 29 | + * coming back. | |
| 30 | 30 | */ |
| 31 | 31 | @OptIn(ExperimentalMaterial3Api::class) |
| 32 | 32 | @Composable |
@@ -36,85 +36,7 @@ fun SearchScreen(
| 36 | 36 | onBack: () -> Unit, |
| 37 | 37 | onOpenUpload: (String) -> Unit, |
| 38 | 38 | ) { |
| 39 | − val creds = remember { tokens.load() } | |
| 40 | − val api: DmsApi? = remember(creds) { creds?.let { DmsClient.build(it) } } | |
| 41 | − | |
| 42 | − var query by remember { mutableStateOf(initialQuery.orEmpty()) } | |
| 43 | − var sources by remember { mutableStateOf(SearchSources()) } | |
| 44 | − var sourcesExpanded by remember { mutableStateOf(false) } | |
| 45 | − | |
| 46 | − var items by remember { mutableStateOf(listOf<UploadSummary>()) } | |
| 47 | − var totalCount by remember { mutableStateOf(0L) } | |
| 48 | − var nextPage by remember { mutableStateOf(1L) } | |
| 49 | − var hasSearched by remember { mutableStateOf(false) } | |
| 50 | − var isRefreshing by remember { mutableStateOf(false) } | |
| 51 | − var isLoadingMore by remember { mutableStateOf(false) } | |
| 52 | − var error by remember { mutableStateOf<String?>(null) } | |
| 53 | − val scope = rememberCoroutineScope() | |
| 54 | − | |
| 55 | − fun on(b: Boolean): String? = if (b) "on" else null | |
| 56 | − | |
| 57 | − suspend fun runSearch() { | |
| 58 | − if (api == null || creds == null) { | |
| 59 | − error = "Not paired"; return | |
| 60 | − } | |
| 61 | − if (query.isBlank() || !sources.any()) { | |
| 62 | − items = emptyList(); totalCount = 0; hasSearched = false; error = null; return | |
| 63 | − } | |
| 64 | − isRefreshing = true | |
| 65 | − hasSearched = true | |
| 66 | − try { | |
| 67 | − val first = api.search( | |
| 68 | − q = query, | |
| 69 | − page = 1, | |
| 70 | − sFilename = on(sources.filename), | |
| 71 | − sItemName = on(sources.itemName), | |
| 72 | − sDescription = on(sources.description), | |
| 73 | − sUserTags = on(sources.userTags), | |
| 74 | − sOcr = on(sources.ocr), | |
| 75 | − sDesc = on(sources.desc), | |
| 76 | − sOtherNs = on(sources.otherNs), | |
| 77 | − ) | |
| 78 | − items = first.items | |
| 79 | − totalCount = first.total | |
| 80 | − nextPage = 2 | |
| 81 | − error = null | |
| 82 | − } catch (e: Exception) { | |
| 83 | − error = e.message ?: "unknown" | |
| 84 | − items = emptyList(); totalCount = 0 | |
| 85 | − } finally { | |
| 86 | − isRefreshing = false | |
| 87 | − } | |
| 88 | − } | |
| 89 | − | |
| 90 | − suspend fun loadMore() { | |
| 91 | − if (api == null || isLoadingMore) return | |
| 92 | − isLoadingMore = true | |
| 93 | − try { | |
| 94 | − val more = api.search( | |
| 95 | − q = query, | |
| 96 | − page = nextPage, | |
| 97 | − sFilename = on(sources.filename), | |
| 98 | − sItemName = on(sources.itemName), | |
| 99 | − sDescription = on(sources.description), | |
| 100 | − sUserTags = on(sources.userTags), | |
| 101 | − sOcr = on(sources.ocr), | |
| 102 | − sDesc = on(sources.desc), | |
| 103 | − sOtherNs = on(sources.otherNs), | |
| 104 | − ) | |
| 105 | − items = items + more.items | |
| 106 | − nextPage += 1 | |
| 107 | − } catch (_: Exception) { | |
| 108 | − } finally { | |
| 109 | − isLoadingMore = false | |
| 110 | − } | |
| 111 | − } | |
| 112 | − | |
| 113 | − LaunchedEffect(initialQuery) { | |
| 114 | − if (!initialQuery.isNullOrBlank()) runSearch() | |
| 115 | − } | |
| 116 | − | |
| 117 | − val canLoadMore = items.size.toLong() < totalCount | |
| 39 | + val vm: SearchViewModel = viewModel { SearchViewModel(tokens, initialQuery) } | |
| 118 | 40 | |
| 119 | 41 | Scaffold( |
| 120 | 42 | topBar = { |
@@ -131,8 +53,8 @@ fun SearchScreen(
| 131 | 53 | Column(modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { |
| 132 | 54 | Spacer(Modifier.height(8.dp)) |
| 133 | 55 | OutlinedTextField( |
| 134 | − value = query, | |
| 135 | − onValueChange = { query = it }, | |
| 56 | + value = vm.query, | |
| 57 | + onValueChange = { vm.query = it }, | |
| 136 | 58 | label = { Text("Query") }, |
| 137 | 59 | placeholder = { Text("phrase, species:macro, species:*, …") }, |
| 138 | 60 | singleLine = true, |
@@ -156,17 +78,17 @@ fun SearchScreen(
| 156 | 78 | .padding(vertical = 4.dp), |
| 157 | 79 | ) { |
| 158 | 80 | Text("Search in", style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f)) |
| 159 | − IconButton(onClick = { sourcesExpanded = !sourcesExpanded }) { | |
| 81 | + IconButton(onClick = { vm.sourcesExpanded = !vm.sourcesExpanded }) { | |
| 160 | 82 | Icon( |
| 161 | − if (sourcesExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, | |
| 162 | − contentDescription = if (sourcesExpanded) "Collapse" else "Expand", | |
| 83 | + if (vm.sourcesExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, | |
| 84 | + contentDescription = if (vm.sourcesExpanded) "Collapse" else "Expand", | |
| 163 | 85 | ) |
| 164 | 86 | } |
| 165 | 87 | } |
| 166 | − if (sourcesExpanded) { | |
| 88 | + if (vm.sourcesExpanded) { | |
| 167 | 89 | SourceToggles( |
| 168 | − sources = sources, | |
| 169 | − onChange = { sources = it }, | |
| 90 | + sources = vm.sources, | |
| 91 | + onChange = { vm.sources = it }, | |
| 170 | 92 | modifier = Modifier.fillMaxWidth(), |
| 171 | 93 | ) |
| 172 | 94 | } |
@@ -176,41 +98,37 @@ fun SearchScreen(
| 176 | 98 | Spacer(Modifier.height(8.dp)) |
| 177 | 99 | Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| 178 | 100 | Button( |
| 179 | − onClick = { scope.launch { runSearch() } }, | |
| 180 | − enabled = !isRefreshing, | |
| 181 | − ) { Text(if (isRefreshing) "Searching…" else "Search") } | |
| 182 | − OutlinedButton(onClick = { | |
| 183 | − query = ""; items = emptyList() | |
| 184 | − totalCount = 0; hasSearched = false; error = null | |
| 185 | − sources = SearchSources() | |
| 186 | − }) { Text("Reset") } | |
| 101 | + onClick = { vm.runSearch() }, | |
| 102 | + enabled = !vm.isRefreshing, | |
| 103 | + ) { Text(if (vm.isRefreshing) "Searching…" else "Search") } | |
| 104 | + OutlinedButton(onClick = { vm.reset() }) { Text("Reset") } | |
| 187 | 105 | } |
| 188 | − error?.let { | |
| 106 | + vm.error?.let { | |
| 189 | 107 | Spacer(Modifier.height(8.dp)) |
| 190 | 108 | Text("Error: $it", color = MaterialTheme.colorScheme.error, |
| 191 | 109 | style = MaterialTheme.typography.bodySmall) |
| 192 | 110 | } |
| 193 | − if (hasSearched) { | |
| 111 | + if (vm.hasSearched) { | |
| 194 | 112 | Spacer(Modifier.height(8.dp)) |
| 195 | 113 | Text( |
| 196 | − "${items.size} of $totalCount loaded", | |
| 114 | + "${vm.items.size} of ${vm.totalCount} loaded", | |
| 197 | 115 | style = MaterialTheme.typography.bodySmall, |
| 198 | 116 | ) |
| 199 | 117 | } |
| 200 | 118 | Spacer(Modifier.height(8.dp)) |
| 201 | 119 | UploadGrid( |
| 202 | − items = items, | |
| 203 | − baseUrl = creds?.baseUrl ?: "", | |
| 204 | − isRefreshing = isRefreshing, | |
| 205 | − onRefresh = { scope.launch { runSearch() } }, | |
| 206 | − isLoadingMore = isLoadingMore, | |
| 207 | − canLoadMore = canLoadMore, | |
| 208 | − onLoadMore = { scope.launch { loadMore() } }, | |
| 120 | + items = vm.items, | |
| 121 | + baseUrl = vm.baseUrl, | |
| 122 | + isRefreshing = vm.isRefreshing, | |
| 123 | + onRefresh = { vm.runSearch() }, | |
| 124 | + isLoadingMore = vm.isLoadingMore, | |
| 125 | + canLoadMore = vm.canLoadMore, | |
| 126 | + onLoadMore = { vm.loadMore() }, | |
| 209 | 127 | onUploadClick = onOpenUpload, |
| 210 | 128 | emptyContent = { |
| 211 | 129 | Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { |
| 212 | 130 | Text( |
| 213 | − if (hasSearched) "Nothing found." | |
| 131 | + if (vm.hasSearched) "Nothing found." | |
| 214 | 132 | else "Enter a phrase, a tag like `species:macro`, or a wildcard like `species:*`.", |
| 215 | 133 | style = MaterialTheme.typography.bodyMedium, |
| 216 | 134 | ) |
added
android/app/src/main/java/org/neiam/dms/app/ui/SearchViewModel.kt
+110
−0
@@ -0,0 +1,110 @@
| 1 | +package org.neiam.dms.app.ui | |
| 2 | + | |
| 3 | +import androidx.compose.runtime.getValue | |
| 4 | +import androidx.compose.runtime.mutableStateOf | |
| 5 | +import androidx.compose.runtime.setValue | |
| 6 | +import androidx.lifecycle.ViewModel | |
| 7 | +import androidx.lifecycle.viewModelScope | |
| 8 | +import kotlinx.coroutines.launch | |
| 9 | +import org.neiam.dms.app.data.DmsApi | |
| 10 | +import org.neiam.dms.app.data.DmsClient | |
| 11 | +import org.neiam.dms.app.data.SearchSources | |
| 12 | +import org.neiam.dms.app.data.TokenStore | |
| 13 | +import org.neiam.dms.app.data.UploadSummary | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * Search state, held outside the composable so it outlives it. Navigation | |
| 17 | + * Compose disposes a destination's plain `remember` state when you open a | |
| 18 | + * result, so search → result → back used to come back to an empty screen. | |
| 19 | + * Scoped to the search back-stack entry: popping back to home still clears it. | |
| 20 | + */ | |
| 21 | +class SearchViewModel(tokens: TokenStore, initialQuery: String?) : ViewModel() { | |
| 22 | + private val creds = tokens.load() | |
| 23 | + private val api: DmsApi? = creds?.let { DmsClient.build(it) } | |
| 24 | + val baseUrl: String = creds?.baseUrl ?: "" | |
| 25 | + | |
| 26 | + var query by mutableStateOf(initialQuery.orEmpty()) | |
| 27 | + var sources by mutableStateOf(SearchSources()) | |
| 28 | + var sourcesExpanded by mutableStateOf(false) | |
| 29 | + | |
| 30 | + var items by mutableStateOf(listOf<UploadSummary>()) | |
| 31 | + private set | |
| 32 | + var totalCount by mutableStateOf(0L) | |
| 33 | + private set | |
| 34 | + var hasSearched by mutableStateOf(false) | |
| 35 | + private set | |
| 36 | + var isRefreshing by mutableStateOf(false) | |
| 37 | + private set | |
| 38 | + var isLoadingMore by mutableStateOf(false) | |
| 39 | + private set | |
| 40 | + var error by mutableStateOf<String?>(null) | |
| 41 | + private set | |
| 42 | + private var nextPage = 1L | |
| 43 | + | |
| 44 | + val canLoadMore: Boolean get() = items.size.toLong() < totalCount | |
| 45 | + | |
| 46 | + init { | |
| 47 | + // Once, here, rather than in a LaunchedEffect that would fire again | |
| 48 | + // (and throw away the loaded pages) every time the screen came back. | |
| 49 | + if (!initialQuery.isNullOrBlank()) runSearch() | |
| 50 | + } | |
| 51 | + | |
| 52 | + private fun on(b: Boolean): String? = if (b) "on" else null | |
| 53 | + | |
| 54 | + private suspend fun fetch(api: DmsApi, page: Long) = api.search( | |
| 55 | + q = query, | |
| 56 | + page = page, | |
| 57 | + sFilename = on(sources.filename), | |
| 58 | + sItemName = on(sources.itemName), | |
| 59 | + sDescription = on(sources.description), | |
| 60 | + sUserTags = on(sources.userTags), | |
| 61 | + sOcr = on(sources.ocr), | |
| 62 | + sDesc = on(sources.desc), | |
| 63 | + sOtherNs = on(sources.otherNs), | |
| 64 | + ) | |
| 65 | + | |
| 66 | + fun runSearch() { | |
| 67 | + val api = api ?: run { error = "Not paired"; return } | |
| 68 | + if (query.isBlank() || !sources.any()) { | |
| 69 | + items = emptyList(); totalCount = 0; hasSearched = false; error = null; return | |
| 70 | + } | |
| 71 | + isRefreshing = true | |
| 72 | + hasSearched = true | |
| 73 | + viewModelScope.launch { | |
| 74 | + try { | |
| 75 | + val first = fetch(api, 1) | |
| 76 | + items = first.items | |
| 77 | + totalCount = first.total | |
| 78 | + nextPage = 2 | |
| 79 | + error = null | |
| 80 | + } catch (e: Exception) { | |
| 81 | + error = e.message ?: "unknown" | |
| 82 | + items = emptyList(); totalCount = 0 | |
| 83 | + } finally { | |
| 84 | + isRefreshing = false | |
| 85 | + } | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + fun loadMore() { | |
| 90 | + val api = api ?: return | |
| 91 | + if (isLoadingMore) return | |
| 92 | + isLoadingMore = true | |
| 93 | + viewModelScope.launch { | |
| 94 | + try { | |
| 95 | + val more = fetch(api, nextPage) | |
| 96 | + items = items + more.items | |
| 97 | + nextPage += 1 | |
| 98 | + } catch (_: Exception) { | |
| 99 | + } finally { | |
| 100 | + isLoadingMore = false | |
| 101 | + } | |
| 102 | + } | |
| 103 | + } | |
| 104 | + | |
| 105 | + fun reset() { | |
| 106 | + query = ""; items = emptyList() | |
| 107 | + totalCount = 0; hasSearched = false; error = null | |
| 108 | + sources = SearchSources() | |
| 109 | + } | |
| 110 | +} |
Parents: f24b73b