git.iko.soy/opencode-android
on 06fd41b help sign in
252 lines 12 kB history raw
1package soy.iko.opencode.ui.search
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.items
15import androidx.compose.foundation.text.KeyboardActions
16import androidx.compose.foundation.text.KeyboardOptions
17import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.filled.Close
19import androidx.compose.material.icons.filled.ErrorOutline
20import androidx.compose.material.icons.filled.Search
21import androidx.compose.material.icons.filled.SearchOff
22import androidx.compose.material3.Card
23import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.ExperimentalMaterial3Api
25import androidx.compose.material3.Icon
26import androidx.compose.material3.IconButton
27import androidx.compose.material3.MaterialTheme
28import androidx.compose.material3.OutlinedTextField
29import androidx.compose.material3.Scaffold
30import androidx.compose.material3.Text
31import androidx.compose.runtime.Composable
32import androidx.compose.runtime.LaunchedEffect
33import androidx.compose.runtime.getValue
34import androidx.compose.runtime.remember
35import androidx.compose.ui.Alignment
36import androidx.compose.ui.Modifier
37import androidx.compose.ui.focus.focusRequester
38import androidx.compose.ui.platform.LocalSoftwareKeyboardController
39import androidx.compose.ui.res.pluralStringResource
40import androidx.compose.ui.res.stringResource
41import androidx.compose.ui.semantics.Role
42import androidx.compose.ui.semantics.contentDescription
43import androidx.compose.ui.semantics.heading
44import androidx.compose.ui.semantics.semantics
45import androidx.compose.ui.text.AnnotatedString
46import androidx.compose.ui.text.SpanStyle
47import androidx.compose.ui.text.buildAnnotatedString
48import androidx.compose.ui.text.font.FontWeight
49import androidx.compose.ui.text.input.ImeAction
50import androidx.compose.ui.text.style.TextOverflow
51import androidx.compose.ui.text.withStyle
52import androidx.compose.ui.unit.dp
53import androidx.lifecycle.compose.collectAsStateWithLifecycle
54import androidx.lifecycle.viewmodel.compose.viewModel
55import soy.iko.opencode.R
56import soy.iko.opencode.di.AppContainer
57import soy.iko.opencode.ui.components.AppTopBar
58import soy.iko.opencode.ui.components.ConnectionBannerFor
59import soy.iko.opencode.ui.components.EmptyState
60import soy.iko.opencode.ui.components.RelativeTimeText
61import soy.iko.opencode.ui.vmFactory
62import soy.iko.opencode.util.runCatchingCancellable
64/** Cross-session message search: type a query, tap a result to open that conversation. */
65@OptIn(ExperimentalMaterial3Api::class)
66@Composable
67fun GlobalSearchScreen(
68 container: AppContainer,
69 onOpenSession: (String) -> Unit,
70 onBack: () -> Unit,
71) {
72 val vm: GlobalSearchViewModel = viewModel(factory = vmFactory { GlobalSearchViewModel(container) })
73 val state by vm.state.collectAsStateWithLifecycle()
74 val focusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
75 val keyboard = LocalSoftwareKeyboardController.current
77 LaunchedEffect(Unit) {
78 runCatchingCancellable { focusRequester.requestFocus() }
79 }
81 Scaffold(
82 topBar = {
83 AppTopBar(title = stringResource(R.string.search_all_title), onBack = onBack)
84 },
85 ) { padding ->
86 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
87 OutlinedTextField(
88 value = state.query,
89 onValueChange = vm::setQuery,
90 modifier = Modifier
91 .fillMaxWidth()
92 .padding(horizontal = 16.dp, vertical = 8.dp)
93 .focusRequester(focusRequester),
94 placeholder = { Text(stringResource(R.string.search_all_hint)) },
95 singleLine = true,
96 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
97 trailingIcon = {
98 if (state.query.isNotEmpty()) {
99 IconButton(onClick = { vm.setQuery("") }) {
100 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
101 }
102 }
103 },
104 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
105 keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }),
106 )
107 Box(modifier = Modifier.fillMaxSize()) {
108 ConnectionBannerFor(container)
109 val searchingLabel = stringResource(R.string.searching)
110 when {
111 state.searching -> CircularProgressIndicator(
112 Modifier.align(Alignment.Center).semantics { contentDescription = searchingLabel },
113 )
114 state.error != null -> EmptyState(
115 icon = Icons.Filled.ErrorOutline,
116 title = state.error ?: "",
117 modifier = Modifier.align(Alignment.Center),
118 actionLabel = stringResource(R.string.retry),
119 onAction = vm::retry,
120 )
121 state.hasSearched && state.results.isEmpty() -> EmptyState(
122 icon = Icons.Filled.SearchOff,
123 title = stringResource(R.string.search_no_matches),
124 modifier = Modifier.align(Alignment.Center),
125 )
126 // Typed something but below the min length (no search run yet): nudge to keep
127 // typing rather than reverting to the untouched start state, which reads as if
128 // the field were empty.
129 state.query.isNotBlank() && !state.hasSearched -> EmptyState(
130 icon = Icons.Filled.Search,
131 title = stringResource(R.string.search_keep_typing),
132 modifier = Modifier.align(Alignment.Center),
133 )
134 state.results.isEmpty() -> EmptyState(
135 icon = Icons.Filled.Search,
136 title = stringResource(R.string.search_all_start),
137 description = stringResource(R.string.search_all_start_hint),
138 modifier = Modifier.align(Alignment.Center),
139 )
140 else -> LazyColumn(
141 modifier = Modifier.fillMaxSize(),
142 contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
143 verticalArrangement = Arrangement.spacedBy(12.dp),
144 ) {
145 item(key = "__count") {
146 Text(
147 pluralStringResource(R.plurals.search_results_count, state.results.size, state.results.size),
148 style = MaterialTheme.typography.labelMedium,
149 color = MaterialTheme.colorScheme.onSurfaceVariant,
150 modifier = Modifier.padding(top = 8.dp).semantics { heading() },
151 )
152 }
153 if (state.truncated) {
154 item(key = "__truncated") {
155 Text(
156 stringResource(R.string.search_truncated),
157 style = MaterialTheme.typography.labelSmall,
158 color = MaterialTheme.colorScheme.onSurfaceVariant,
159 modifier = Modifier.padding(vertical = 4.dp),
160 )
161 }
162 }
163 items(state.results, key = { it.session.id }) { hit ->
164 SearchResultCard(
165 hit = hit,
166 query = state.query.trim(),
167 onClick = { onOpenSession(hit.session.id) },
168 )
169 }
170 }
171 }
172 }
173 }
174 }
177@Composable
178private fun SearchResultCard(hit: SearchHit, query: String, onClick: () -> Unit) {
179 val highlightColor = MaterialTheme.colorScheme.primary
180 // Highlight the title too when the match was in the title (otherwise the body snippet below
181 // is highlighted but the title above stays plain — an inconsistency).
182 val title = remember(hit.session.displayTitle, query, hit.matchedTitle, highlightColor) {
183 if (hit.matchedTitle) highlightMatches(hit.session.displayTitle, query, highlightColor)
184 else AnnotatedString(hit.session.displayTitle)
185 }
186 Card(modifier = Modifier.fillMaxWidth().clickable(role = Role.Button) { onClick() }) {
187 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
188 Row(verticalAlignment = Alignment.CenterVertically) {
189 Text(
190 title,
191 style = MaterialTheme.typography.titleMedium,
192 maxLines = 1,
193 overflow = TextOverflow.Ellipsis,
194 modifier = Modifier.weight(1f),
195 )
196 // Show how many times the query occurs so the user knows whether the session is
197 // worth opening (multiple hits) instead of seeing only the single returned snippet.
198 if (hit.matchCount > 1) {
199 val countLabel = pluralStringResource(R.plurals.search_results_count, hit.matchCount, hit.matchCount)
200 Text(
201 countLabel,
202 style = MaterialTheme.typography.labelSmall,
203 color = MaterialTheme.colorScheme.primary,
204 modifier = Modifier.padding(start = 8.dp),
205 )
206 }
207 }
208 RelativeTimeText(
209 hit.session.time?.updated ?: hit.session.time?.created,
210 modifier = Modifier.padding(top = 2.dp),
211 )
212 if (hit.snippet.isNotEmpty()) {
213 val snippet = remember(hit.snippet, query, highlightColor) {
214 highlightMatches(hit.snippet, query, highlightColor)
215 }
216 Text(
217 snippet,
218 style = MaterialTheme.typography.bodyMedium,
219 color = MaterialTheme.colorScheme.onSurfaceVariant,
220 maxLines = 3,
221 overflow = TextOverflow.Ellipsis,
222 modifier = Modifier.padding(top = 6.dp),
223 )
224 }
225 }
226 }
229/** Bold + tint every case-insensitive occurrence of [query] within [text] so the user can
230 * see where the hit is in the surrounding snippet. */
231private fun highlightMatches(
232 text: String,
233 query: String,
234 color: androidx.compose.ui.graphics.Color,
235): AnnotatedString {
236 if (query.isEmpty()) return AnnotatedString(text)
237 return buildAnnotatedString {
238 var start = 0
239 while (true) {
240 val idx = text.indexOf(query, start, ignoreCase = true)
241 if (idx < 0) {
242 append(text.substring(start))
243 break
244 }
245 append(text.substring(start, idx))
246 withStyle(SpanStyle(color = color, fontWeight = FontWeight.Bold)) {
247 append(text.substring(idx, idx + query.length))
248 }
249 start = idx + query.length
250 }
251 }