Skip to content

Commit 630130a

Browse files
feat: Add comprehensive OpenSearch integration for all frontend platforms
- PWA: Added searchService.ts with types, interfaces, service methods, and React hooks - PWA: Added SearchBar component with autocomplete, suggestions, recent searches, debouncing - PWA: Updated Transactions, Beneficiaries, AuditLogs pages to use OpenSearch - Android: Added SearchService.kt with Retrofit/OkHttp client for search API - Android: Added SearchBar composable with autocomplete and suggestions - iOS: Added SearchService.swift with URLSession client for search API - iOS: Added SearchBarView with autocomplete and suggestions All frontends now connect to unified search service endpoints with: - Support for all indices (transactions, users, beneficiaries, disputes, audit_logs, kyc, wallets, cards, bills, airtime) - Filtering, sorting, pagination, highlighting - Autocomplete suggestions and recent searches - Fallback to mock data when API unavailable Co-Authored-By: Patrick Munis <pmunis@gmail.com>
1 parent 3285069 commit 630130a

File tree

9 files changed

+2697
-63
lines changed

9 files changed

+2697
-63
lines changed

android-native/app/src/main/kotlin/com/remittance/app/data/remote/SearchService.kt

Lines changed: 574 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
package com.remittance.app.ui.components
2+
3+
import androidx.compose.animation.AnimatedVisibility
4+
import androidx.compose.animation.fadeIn
5+
import androidx.compose.animation.fadeOut
6+
import androidx.compose.foundation.background
7+
import androidx.compose.foundation.clickable
8+
import androidx.compose.foundation.layout.*
9+
import androidx.compose.foundation.lazy.LazyColumn
10+
import androidx.compose.foundation.lazy.items
11+
import androidx.compose.foundation.shape.RoundedCornerShape
12+
import androidx.compose.foundation.text.KeyboardActions
13+
import androidx.compose.foundation.text.KeyboardOptions
14+
import androidx.compose.material.icons.Icons
15+
import androidx.compose.material.icons.filled.Clear
16+
import androidx.compose.material.icons.filled.History
17+
import androidx.compose.material.icons.filled.Search
18+
import androidx.compose.material3.*
19+
import androidx.compose.runtime.*
20+
import androidx.compose.ui.Alignment
21+
import androidx.compose.ui.Modifier
22+
import androidx.compose.ui.draw.clip
23+
import androidx.compose.ui.focus.FocusRequester
24+
import androidx.compose.ui.focus.focusRequester
25+
import androidx.compose.ui.focus.onFocusChanged
26+
import androidx.compose.ui.graphics.Color
27+
import androidx.compose.ui.platform.LocalFocusManager
28+
import androidx.compose.ui.text.SpanStyle
29+
import androidx.compose.ui.text.buildAnnotatedString
30+
import androidx.compose.ui.text.font.FontWeight
31+
import androidx.compose.ui.text.input.ImeAction
32+
import androidx.compose.ui.text.withStyle
33+
import androidx.compose.ui.unit.dp
34+
import androidx.compose.ui.unit.sp
35+
import com.remittance.app.data.remote.RecentSearch
36+
import com.remittance.app.data.remote.SearchIndex
37+
import com.remittance.app.data.remote.SearchSuggestion
38+
import kotlinx.coroutines.Job
39+
import kotlinx.coroutines.delay
40+
import kotlinx.coroutines.launch
41+
42+
/**
43+
* OpenSearch-integrated SearchBar component for Android
44+
* Features: autocomplete, suggestions, recent searches, debouncing
45+
*/
46+
@OptIn(ExperimentalMaterial3Api::class)
47+
@Composable
48+
fun SearchBar(
49+
modifier: Modifier = Modifier,
50+
placeholder: String = "Search...",
51+
index: SearchIndex? = null,
52+
onSearch: (String) -> Unit,
53+
onSuggestionsFetch: suspend (String) -> List<SearchSuggestion> = { emptyList() },
54+
onRecentSearchesFetch: suspend () -> List<RecentSearch> = { emptyList() },
55+
onSaveRecentSearch: suspend (String) -> Unit = {},
56+
debounceMs: Long = 300L,
57+
showSuggestions: Boolean = true,
58+
showRecentSearches: Boolean = true
59+
) {
60+
var query by remember { mutableStateOf("") }
61+
var isExpanded by remember { mutableStateOf(false) }
62+
var suggestions by remember { mutableStateOf<List<SearchSuggestion>>(emptyList()) }
63+
var recentSearches by remember { mutableStateOf<List<RecentSearch>>(emptyList()) }
64+
var isLoading by remember { mutableStateOf(false) }
65+
66+
val focusRequester = remember { FocusRequester() }
67+
val focusManager = LocalFocusManager.current
68+
val scope = rememberCoroutineScope()
69+
var debounceJob by remember { mutableStateOf<Job?>(null) }
70+
71+
// Load recent searches when focused
72+
LaunchedEffect(isExpanded) {
73+
if (isExpanded && showRecentSearches && query.isEmpty()) {
74+
recentSearches = onRecentSearchesFetch()
75+
}
76+
}
77+
78+
// Debounced suggestions fetch
79+
LaunchedEffect(query) {
80+
if (query.length >= 2 && showSuggestions) {
81+
debounceJob?.cancel()
82+
debounceJob = scope.launch {
83+
delay(debounceMs)
84+
isLoading = true
85+
suggestions = onSuggestionsFetch(query)
86+
isLoading = false
87+
}
88+
} else {
89+
suggestions = emptyList()
90+
}
91+
}
92+
93+
Column(modifier = modifier) {
94+
// Search Input Field
95+
OutlinedTextField(
96+
value = query,
97+
onValueChange = { newValue ->
98+
query = newValue
99+
if (newValue.isEmpty()) {
100+
suggestions = emptyList()
101+
}
102+
},
103+
modifier = Modifier
104+
.fillMaxWidth()
105+
.focusRequester(focusRequester)
106+
.onFocusChanged { focusState ->
107+
isExpanded = focusState.isFocused
108+
},
109+
placeholder = { Text(placeholder) },
110+
leadingIcon = {
111+
Icon(
112+
imageVector = Icons.Default.Search,
113+
contentDescription = "Search",
114+
tint = MaterialTheme.colorScheme.onSurfaceVariant
115+
)
116+
},
117+
trailingIcon = {
118+
if (query.isNotEmpty()) {
119+
IconButton(onClick = {
120+
query = ""
121+
suggestions = emptyList()
122+
onSearch("")
123+
}) {
124+
Icon(
125+
imageVector = Icons.Default.Clear,
126+
contentDescription = "Clear",
127+
tint = MaterialTheme.colorScheme.onSurfaceVariant
128+
)
129+
}
130+
} else if (isLoading) {
131+
CircularProgressIndicator(
132+
modifier = Modifier.size(20.dp),
133+
strokeWidth = 2.dp
134+
)
135+
}
136+
},
137+
keyboardOptions = KeyboardOptions(
138+
imeAction = ImeAction.Search
139+
),
140+
keyboardActions = KeyboardActions(
141+
onSearch = {
142+
if (query.isNotEmpty()) {
143+
scope.launch {
144+
onSaveRecentSearch(query)
145+
}
146+
onSearch(query)
147+
focusManager.clearFocus()
148+
isExpanded = false
149+
}
150+
}
151+
),
152+
singleLine = true,
153+
shape = RoundedCornerShape(12.dp),
154+
colors = OutlinedTextFieldDefaults.colors(
155+
focusedBorderColor = MaterialTheme.colorScheme.primary,
156+
unfocusedBorderColor = MaterialTheme.colorScheme.outline
157+
)
158+
)
159+
160+
// Dropdown for suggestions and recent searches
161+
AnimatedVisibility(
162+
visible = isExpanded && (suggestions.isNotEmpty() || (recentSearches.isNotEmpty() && query.isEmpty())),
163+
enter = fadeIn(),
164+
exit = fadeOut()
165+
) {
166+
Card(
167+
modifier = Modifier
168+
.fillMaxWidth()
169+
.padding(top = 4.dp),
170+
shape = RoundedCornerShape(12.dp),
171+
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
172+
) {
173+
LazyColumn(
174+
modifier = Modifier
175+
.fillMaxWidth()
176+
.heightIn(max = 300.dp)
177+
) {
178+
// Show suggestions if query is not empty
179+
if (query.isNotEmpty() && suggestions.isNotEmpty()) {
180+
items(suggestions) { suggestion ->
181+
SuggestionItem(
182+
suggestion = suggestion,
183+
query = query,
184+
onClick = {
185+
query = suggestion.text
186+
scope.launch {
187+
onSaveRecentSearch(suggestion.text)
188+
}
189+
onSearch(suggestion.text)
190+
focusManager.clearFocus()
191+
isExpanded = false
192+
}
193+
)
194+
}
195+
}
196+
197+
// Show recent searches if query is empty
198+
if (query.isEmpty() && recentSearches.isNotEmpty()) {
199+
item {
200+
Text(
201+
text = "Recent Searches",
202+
style = MaterialTheme.typography.labelMedium,
203+
color = MaterialTheme.colorScheme.onSurfaceVariant,
204+
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
205+
)
206+
}
207+
items(recentSearches.take(5)) { recentSearch ->
208+
RecentSearchItem(
209+
recentSearch = recentSearch,
210+
onClick = {
211+
query = recentSearch.query
212+
onSearch(recentSearch.query)
213+
focusManager.clearFocus()
214+
isExpanded = false
215+
}
216+
)
217+
}
218+
}
219+
}
220+
}
221+
}
222+
}
223+
}
224+
225+
@Composable
226+
private fun SuggestionItem(
227+
suggestion: SearchSuggestion,
228+
query: String,
229+
onClick: () -> Unit
230+
) {
231+
Row(
232+
modifier = Modifier
233+
.fillMaxWidth()
234+
.clickable(onClick = onClick)
235+
.padding(horizontal = 16.dp, vertical = 12.dp),
236+
verticalAlignment = Alignment.CenterVertically
237+
) {
238+
Icon(
239+
imageVector = Icons.Default.Search,
240+
contentDescription = null,
241+
tint = MaterialTheme.colorScheme.onSurfaceVariant,
242+
modifier = Modifier.size(20.dp)
243+
)
244+
Spacer(modifier = Modifier.width(12.dp))
245+
Text(
246+
text = highlightMatch(suggestion.text, query),
247+
style = MaterialTheme.typography.bodyMedium
248+
)
249+
Spacer(modifier = Modifier.weight(1f))
250+
Text(
251+
text = suggestion.index,
252+
style = MaterialTheme.typography.labelSmall,
253+
color = MaterialTheme.colorScheme.primary
254+
)
255+
}
256+
}
257+
258+
@Composable
259+
private fun RecentSearchItem(
260+
recentSearch: RecentSearch,
261+
onClick: () -> Unit
262+
) {
263+
Row(
264+
modifier = Modifier
265+
.fillMaxWidth()
266+
.clickable(onClick = onClick)
267+
.padding(horizontal = 16.dp, vertical = 12.dp),
268+
verticalAlignment = Alignment.CenterVertically
269+
) {
270+
Icon(
271+
imageVector = Icons.Default.History,
272+
contentDescription = null,
273+
tint = MaterialTheme.colorScheme.onSurfaceVariant,
274+
modifier = Modifier.size(20.dp)
275+
)
276+
Spacer(modifier = Modifier.width(12.dp))
277+
Text(
278+
text = recentSearch.query,
279+
style = MaterialTheme.typography.bodyMedium
280+
)
281+
recentSearch.index?.let { index ->
282+
Spacer(modifier = Modifier.weight(1f))
283+
Text(
284+
text = index,
285+
style = MaterialTheme.typography.labelSmall,
286+
color = MaterialTheme.colorScheme.onSurfaceVariant
287+
)
288+
}
289+
}
290+
}
291+
292+
@Composable
293+
private fun highlightMatch(text: String, query: String) = buildAnnotatedString {
294+
val lowerText = text.lowercase()
295+
val lowerQuery = query.lowercase()
296+
var startIndex = 0
297+
298+
while (true) {
299+
val matchIndex = lowerText.indexOf(lowerQuery, startIndex)
300+
if (matchIndex == -1) {
301+
append(text.substring(startIndex))
302+
break
303+
}
304+
305+
// Append text before match
306+
append(text.substring(startIndex, matchIndex))
307+
308+
// Append highlighted match
309+
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = Color(0xFF1976D2))) {
310+
append(text.substring(matchIndex, matchIndex + query.length))
311+
}
312+
313+
startIndex = matchIndex + query.length
314+
}
315+
}

0 commit comments

Comments
 (0)