From bc69954c93418f10ec042c9859e8af5f8c747ad6 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 1 Apr 2026 15:54:35 +0200 Subject: [PATCH] Mock search functionality... --- .../repository/DatabaseSearchRepository.kt | 16 + .../compose/repository/SearchRepository.kt | 18 + .../aux/compose/ui/composable/SearchScreen.kt | 368 +++++++++++++++++- .../ui/composable/navigation/AuxNavHost.kt | 14 +- .../compose/ui/view/model/SearchViewModel.kt | 84 ++++ .../compose/ui/view/state/SearchUIState.kt | 15 + 6 files changed, 499 insertions(+), 16 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseSearchRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/repository/SearchRepository.kt create mode 100755 composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/SearchViewModel.kt create mode 100755 composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/state/SearchUIState.kt diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseSearchRepository.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseSearchRepository.kt new file mode 100644 index 00000000..146b1858 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseSearchRepository.kt @@ -0,0 +1,16 @@ +package ac.aux.compose.database.repository + +import ac.aux.compose.database.AuxDatabase +import ac.aux.compose.database.model.SynchronizeNostrEventRequest +import ac.aux.compose.repository.SearchRepository + +class DatabaseSearchRepository( + private val database: AuxDatabase +): SearchRepository { + + override suspend fun submitSearch(synchronizeNostrEventRequests: List) { + database.synchronizeNostrEventRequestDao().insert( + synchronizeNostrEventRequests = synchronizeNostrEventRequests + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/SearchRepository.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/SearchRepository.kt new file mode 100644 index 00000000..db371af3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/SearchRepository.kt @@ -0,0 +1,18 @@ +package ac.aux.compose.repository + +import ac.aux.compose.database.model.SynchronizeNostrEventRequest + +interface SearchRepository { + + suspend fun submitSearch( + synchronizeNostrEventRequests: List, + ) + + companion object { + val NO_OP_SEARCH_REPOSITORY = object: SearchRepository { + override suspend fun submitSearch(synchronizeNostrEventRequests: List) { + TODO("Not yet implemented") + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/SearchScreen.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/SearchScreen.kt index 70ebb154..882a1e29 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/SearchScreen.kt @@ -1,35 +1,377 @@ package ac.aux.compose.ui.composable +import ac.aux.compose.repository.NostrRepository +import ac.aux.compose.repository.SearchRepository import ac.aux.compose.ui.theme.AuxTheme +import ac.aux.compose.ui.view.model.FeedListViewModel +import ac.aux.compose.ui.view.model.SearchViewModel +import ac.aux.compose.ui.view.state.SearchUIState +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBackIosNew +import androidx.compose.material.icons.filled.ManageSearch +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material.icons.filled.TravelExplore +import androidx.compose.material.icons.filled.YoutubeSearchedFor +import androidx.compose.material3.AppBarWithSearch +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExpandedFullScreenSearchBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopSearchBar +import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun SearchScreen() { +fun SearchScreen( + initialSearchUIState: SearchUIState, + nostrRepository: NostrRepository, + searchRepository: SearchRepository +) { + val searchViewModel: SearchViewModel = viewModel( + factory = SearchViewModel.factory( + initialSearchUIState = initialSearchUIState, + nostrRepository = nostrRepository, + searchRepository = searchRepository + ), + ) + + val searchResults = listOf( + "Apple", + "Banna", + "Carrot", + "Durian", + "Eggplant", + "Fig", + "Grape", + "Honeydew", + "Jackfruit", + "Kiwi", + "Lemon", + "Mango", + "Nectarine", + "Orange", + "Papaya", + "Quince", + "Raspberry", + "Tangerine", + "Ugli Fruit", + "Vanilla Bean", + "Watermelon", + "Xigua", + "Yellow Passion Fruit", + "Zinfandel", + ) + + val recentSearches = listOf( + "Alligator", + "Baboon", + "Camel", + "Deer", + "Earthworm", + "Falcon", + "Gazelle", + "Hamster", + "Iguana", + "Jellyfish", + "Koala", + "Leopard", + "Meerkat", + "Nilgai", + "Octopus", + "Panda", + "Quokka", + "Panda", + "Rabbit", + "Scorpion", + "Tarantula", + "Uakari", + "Viper", + "Walrus", + "Xenops", + "Yak", + "Zebra", + ) + + val profileSearches = listOf( + "Afghanistan", + "Bahamas", + "Cambodia", + "Denmark", + "Egypt", + "Finland", + "Gabon", + "Haiti", + "Iran", + "Jamaica", + "Kazakhstan", + "Lesotho", + "Malawi", + "Namibia", + "Oman", + "Pakistan", + "Qatar", + "Russia", + "Samoa", + "Senegal", + "Tanzania", + "Uganda", + "Venezuela", + "Yemen", + "Zambia" + ) + val textFieldState = rememberTextFieldState() + // Controls expansion state of the search bar + var expanded by rememberSaveable { mutableStateOf(false) } + Scaffold { innerPadding -> Column( - modifier = Modifier.padding(innerPadding).fillMaxSize() + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - Column( - modifier = Modifier.padding(20.dp).fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - "\"Search\" functionality coming soon", - ) + Box( + modifier = Modifier + .fillMaxSize() + .semantics { isTraversalGroup = true } + ) { + + SearchBar( + modifier = Modifier + .align(Alignment.TopCenter) + .semantics { traversalIndex = 0f }, + inputField = { + SearchBarDefaults.InputField( + query = textFieldState.text.toString(), + onQueryChange = { textFieldState.edit { replace(0, length, it) } }, + onSearch = { + searchViewModel.performSearch( + textFieldState.text.toString() + ) + expanded = false + }, + expanded = expanded, + onExpandedChange = { expanded = it }, + placeholder = { Text("Search") }, + leadingIcon = { + if (expanded) { + IconButton( + onClick = { + expanded = false + } + ) { + Icon( + Icons.Default.ArrowBackIosNew, + contentDescription = "Back" + ) + } + + } else { + Icon( + Icons.Default.Search, + contentDescription = "Search" + ) + } + } + ) + }, + expanded = expanded, + onExpandedChange = { expanded = it }, + windowInsets = TopAppBarDefaults.windowInsets + ) { + if (textFieldState.text.isNotBlank()) { + // Display search results in a scrollable column + LazyColumn { + itemsIndexed(searchResults) { index, item -> + ListItem( + headlineContent = { Text(item) }, + supportingContent = { + if (index == 0) { + Text( + text = "Search" + ) + } else if (index == 1) { + Text( + text = "Search hashtags" + ) + } else { + Text("Profile") + } + }, + leadingContent = { + if (index == 0) { + Icon( + Icons.Default.TravelExplore, + contentDescription = "Search globally" + ) + } else if (index == 1) { + Icon( + Icons.Default.Tag, + contentDescription = "Search hashtags" + ) + } else { + Icon( + Icons.Default.Person, + contentDescription = "Person" + ) + } + }, + modifier = Modifier + .clickable { + textFieldState.edit { replace(0, length, item) } + expanded = false + } + .fillMaxWidth(), + + ) + } + } + } else { + + // Display search results in a scrollable column + LazyColumn { + item { + Column( + modifier = Modifier.padding(10.dp), + + ) { + Text( + text = "Profiles", + style = MaterialTheme.typography.titleMedium + ) + } + + } + item { + LazyRow { + items(profileSearches) { profile -> + Column( + modifier = Modifier.padding(10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + IconButton( + onClick = { + // TODO: Open profile... + } + ) { + Icon( + Icons.Default.Person, + contentDescription = profile + ) + } + + Text( + text = profile, + style = MaterialTheme.typography.labelSmall + ) + } + + } + + } + } + item { + Column( + modifier = Modifier.padding(10.dp), + + ) { + Text( + text = "Recents", + style = MaterialTheme.typography.titleMedium + ) + } + } + itemsIndexed(recentSearches) { index, item -> + ListItem( + colors = ListItemDefaults.colors( + containerColor = Color.Transparent + ), + headlineContent = { Text(item) }, + leadingContent = { + Icon( + Icons.Default.YoutubeSearchedFor, + contentDescription = "Person" + ) + }, + modifier = Modifier + .clickable { + textFieldState.edit { replace(0, length, item) } + expanded = false + } + .fillMaxWidth(), + ) + } + } + } + } + + Column( + modifier = Modifier.padding(20.dp).fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Doing this to have a way to know how much space the search bar is actually taking up. + TopAppBar( + title = {} + ) + Text( + text = "Trending notes functionality coming soon. In the meantime search for what you are looking for.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + } } } } @@ -42,7 +384,11 @@ private fun SearchScreenPreview() { Surface( modifier = Modifier.padding(20.dp) ) { - SearchScreen() + SearchScreen( + initialSearchUIState = SearchUIState.Prompt, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + searchRepository = SearchRepository.NO_OP_SEARCH_REPOSITORY + ) } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/navigation/AuxNavHost.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/navigation/AuxNavHost.kt index f564e34d..dda8a51b 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/navigation/AuxNavHost.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/navigation/AuxNavHost.kt @@ -2,6 +2,7 @@ package ac.aux.compose.ui.composable.navigation import ac.aux.compose.AuxGlobal import ac.aux.compose.database.repository.DatabaseNostrRepository +import ac.aux.compose.database.repository.DatabaseSearchRepository import ac.aux.compose.managers.DatabaseManager import ac.aux.compose.ui.composable.CreateProfileScreen import ac.aux.compose.ui.composable.FeedScreen @@ -45,6 +46,7 @@ import ac.aux.compose.ui.view.model.NavigationViewModel import ac.aux.compose.ui.view.state.FeedListUIState import ac.aux.compose.ui.view.state.NavigationUIState import ac.aux.compose.ui.view.state.NostrEventDetailUIState +import ac.aux.compose.ui.view.state.SearchUIState import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.runtime.LaunchedEffect @@ -73,7 +75,7 @@ fun AuxNavHost( val nostrRepository = DatabaseNostrRepository( database = databaseManager.auxDatabase ) - val inMemoryNostrRepository = DatabaseNostrRepository( + val searchRepository = DatabaseSearchRepository( database = databaseManager.inMemoryAuxDatabase ) @@ -327,10 +329,12 @@ fun AuxNavHost( } } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - SearchScreen() + composable { + SearchScreen( + initialSearchUIState = SearchUIState.Prompt, + nostrRepository = nostrRepository, + searchRepository = searchRepository + ) } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/SearchViewModel.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/SearchViewModel.kt new file mode 100755 index 00000000..89ac2b02 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/SearchViewModel.kt @@ -0,0 +1,84 @@ +package ac.aux.compose.ui.view.model + +import ac.aux.compose.database.model.SynchronizeNostrEventRequest +import ac.aux.compose.database.model.types.SynchronizationFilter +import ac.aux.compose.managers.SeedManager +import ac.aux.compose.nostr.Relays +import ac.aux.compose.repository.NostrRepository +import ac.aux.compose.repository.SearchRepository +import ac.aux.compose.ui.view.state.SearchUIState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +class SearchViewModel( + initialSearchUIState: SearchUIState, + val nostrRepository: NostrRepository, + val searchRepository: SearchRepository, +): ViewModel() { + val logger = Logger.withTag(TAG) + + var searchUIState: SearchUIState by mutableStateOf(initialSearchUIState) + private set + + + fun performSearch(searchQuery: String) { + viewModelScope.launch(Dispatchers.IO) { + // Sync Notifications... might want to also run this in the background + searchRepository.submitSearch( + Relays.eventPublishRelaySet.map { normalizedRelay -> + SynchronizeNostrEventRequest( + purpose = "search", + synchronizationFilters = arrayOf( + SynchronizationFilter( + kinds = arrayOf( + TextNoteEvent.KIND, + RepostEvent.KIND, + ReactionEvent.KIND, + LnZapEvent.KIND, + ), + search = "shoes", + limit = 50 + ) + ), + relayURL = normalizedRelay.url + ) + } + ) + } + } + + + companion object { + const val TAG = "SearchViewModel" + + fun factory( + initialSearchUIState: SearchUIState = SearchUIState.Prompt, + nostrRepository: NostrRepository, + searchRepository: SearchRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + SearchViewModel( + initialSearchUIState = initialSearchUIState, + nostrRepository = nostrRepository, + searchRepository = searchRepository + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/state/SearchUIState.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/state/SearchUIState.kt new file mode 100755 index 00000000..69d4954d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/state/SearchUIState.kt @@ -0,0 +1,15 @@ +package ac.aux.compose.ui.view.state + +import ac.aux.compose.database.model.intermdiate.LocalNostrEvent + +sealed interface SearchUIState { + data class Loaded( + val localNostrEvents: List + ): SearchUIState + + data object Error: SearchUIState + data object Prompt: SearchUIState + + data object LandingSearch: SearchUIState +} +