Android: keep search results across opening one and coming back

6f01817 · Gabriel Morell · 2026-09-26 04:14

12 files +138 -110
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