From 62d008f15d4563fdfde515a37c6e26131e0efaa0 Mon Sep 17 00:00:00 2001 From: Aayush Gupta Date: Mon, 25 Mar 2024 23:50:31 +0530 Subject: [PATCH] Add support for searching threads and nodes Signed-off-by: Aayush Gupta --- .../io/aayush/relabs/network/XDAInterface.kt | 25 +++ .../io/aayush/relabs/network/XDARepository.kt | 30 ++++ .../relabs/network/data/search/Order.kt | 8 + .../relabs/network/data/search/PostSearch.kt | 5 + .../relabs/network/data/search/Search.kt | 12 ++ .../network/data/search/SearchConstraints.kt | 6 + .../network/data/search/SearchResultNode.kt | 10 ++ .../network/data/search/SearchResultThread.kt | 10 ++ .../aayush/relabs/network/data/search/Type.kt | 6 + .../threadpreview/ThreadPreviewScreen.kt | 154 +++++++++++++----- .../threadpreview/ThreadPreviewViewModel.kt | 30 ++++ app/src/main/res/values/strings.xml | 1 + 12 files changed, 255 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/io/aayush/relabs/network/data/search/Order.kt create mode 100644 app/src/main/java/io/aayush/relabs/network/data/search/PostSearch.kt create mode 100644 app/src/main/java/io/aayush/relabs/network/data/search/Search.kt create mode 100644 app/src/main/java/io/aayush/relabs/network/data/search/SearchConstraints.kt create mode 100644 app/src/main/java/io/aayush/relabs/network/data/search/SearchResultNode.kt create mode 100644 app/src/main/java/io/aayush/relabs/network/data/search/SearchResultThread.kt create mode 100644 app/src/main/java/io/aayush/relabs/network/data/search/Type.kt diff --git a/app/src/main/java/io/aayush/relabs/network/XDAInterface.kt b/app/src/main/java/io/aayush/relabs/network/XDAInterface.kt index 0eb9739..80ee9a6 100644 --- a/app/src/main/java/io/aayush/relabs/network/XDAInterface.kt +++ b/app/src/main/java/io/aayush/relabs/network/XDAInterface.kt @@ -7,6 +7,10 @@ import io.aayush.relabs.network.data.node.Nodes import io.aayush.relabs.network.data.post.PostInfo import io.aayush.relabs.network.data.post.PostReply import io.aayush.relabs.network.data.react.PostReact +import io.aayush.relabs.network.data.search.Order +import io.aayush.relabs.network.data.search.PostSearch +import io.aayush.relabs.network.data.search.SearchResultNode +import io.aayush.relabs.network.data.search.SearchResultThread import io.aayush.relabs.network.data.thread.ThreadInfo import io.aayush.relabs.network.data.thread.Threads import io.aayush.relabs.network.data.user.Me @@ -105,4 +109,25 @@ interface XDAInterface { @POST("audapp-push-subscriptions") suspend fun postExpoPushToken(@Body body: RequestBody): Response + + @POST("audapp-search") + suspend fun postSearch( + @Query("keywords") query: String, + @Query("search_type") type: String, + @Query("c[container_only]") searchThreadConstraint: Int? = null, + @Query("c[title_only]") searchTitleConstraint: Int? = null, + @Query("order") order: String = Order.RELEVANCE.value, + ): Response + + @GET("audapp-search/{id}") + suspend fun getSearchResultsForThread( + @Path("id") searchID: Int, + @Query("page") page: Int? = null, + ): Response + + @GET("audapp-search/{id}") + suspend fun getSearchResultsForNode( + @Path("id") searchID: Int, + @Query("page") page: Int? = null, + ): Response } diff --git a/app/src/main/java/io/aayush/relabs/network/XDARepository.kt b/app/src/main/java/io/aayush/relabs/network/XDARepository.kt index bc4b1e7..84d2f76 100644 --- a/app/src/main/java/io/aayush/relabs/network/XDARepository.kt +++ b/app/src/main/java/io/aayush/relabs/network/XDARepository.kt @@ -11,12 +11,14 @@ import io.aayush.relabs.network.data.post.PostInfo import io.aayush.relabs.network.data.post.PostReply import io.aayush.relabs.network.data.react.PostReact import io.aayush.relabs.network.data.react.React +import io.aayush.relabs.network.data.search.Type import io.aayush.relabs.network.data.thread.Thread import io.aayush.relabs.network.data.thread.ThreadInfo import io.aayush.relabs.network.data.thread.Threads import io.aayush.relabs.network.data.user.Me import io.aayush.relabs.network.paging.GenericPagingSource.Companion.createPager import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import java.util.UUID import okhttp3.MultipartBody import retrofit2.Response @@ -136,6 +138,34 @@ class XDARepository @Inject constructor( return safeExecute { xdaInterface.getWatchedNodes() }?.nodes } + fun getSearchResultsForThreads(query: String): Flow> { + return createPager { page -> + val search = safeExecute { xdaInterface.postSearch(query, Type.THREAD.value) }?.search + + if (search != null && search.id != 0) { + safeExecute { + xdaInterface.getSearchResultsForThread(search.id, page) + }?.results.orEmpty() + } else { + emptyList() + } + }.flow + } + + fun getSearchResultsForNodes(query: String): Flow> { + return createPager { page -> + val search = safeExecute { xdaInterface.postSearch(query, Type.NODE.value) }?.search + + if (search != null && search.id != 0) { + safeExecute { + xdaInterface.getSearchResultsForNode(search.id, page) + }?.results.orEmpty() + } else { + emptyList() + } + }.flow + } + private inline fun safeExecute(block: () -> Response): T? { return try { val response = block() diff --git a/app/src/main/java/io/aayush/relabs/network/data/search/Order.kt b/app/src/main/java/io/aayush/relabs/network/data/search/Order.kt new file mode 100644 index 0000000..1121990 --- /dev/null +++ b/app/src/main/java/io/aayush/relabs/network/data/search/Order.kt @@ -0,0 +1,8 @@ +package io.aayush.relabs.network.data.search + +enum class Order(val value: String) { + RELEVANCE("relevance"), + DATE("date"), + MOST_RECENT("last_update"), + MOST_REPLIES("replies") +} diff --git a/app/src/main/java/io/aayush/relabs/network/data/search/PostSearch.kt b/app/src/main/java/io/aayush/relabs/network/data/search/PostSearch.kt new file mode 100644 index 0000000..f33ff7f --- /dev/null +++ b/app/src/main/java/io/aayush/relabs/network/data/search/PostSearch.kt @@ -0,0 +1,5 @@ +package io.aayush.relabs.network.data.search + +data class PostSearch( + val search: Search = Search() +) diff --git a/app/src/main/java/io/aayush/relabs/network/data/search/Search.kt b/app/src/main/java/io/aayush/relabs/network/data/search/Search.kt new file mode 100644 index 0000000..9da5f4f --- /dev/null +++ b/app/src/main/java/io/aayush/relabs/network/data/search/Search.kt @@ -0,0 +1,12 @@ +package io.aayush.relabs.network.data.search + +import io.aayush.relabs.network.data.common.DateTime + +data class Search( + val created_at: DateTime = DateTime(), + val id: Int = 0, + val result_count: Int = 0, + val search_constraints: List = emptyList(), + val search_order: String = String(), + val search_type: String = String() +) diff --git a/app/src/main/java/io/aayush/relabs/network/data/search/SearchConstraints.kt b/app/src/main/java/io/aayush/relabs/network/data/search/SearchConstraints.kt new file mode 100644 index 0000000..0e23aff --- /dev/null +++ b/app/src/main/java/io/aayush/relabs/network/data/search/SearchConstraints.kt @@ -0,0 +1,6 @@ +package io.aayush.relabs.network.data.search + +data class SearchConstraints( + val container_only: Int = 0, + val title_only: Int = 0 +) diff --git a/app/src/main/java/io/aayush/relabs/network/data/search/SearchResultNode.kt b/app/src/main/java/io/aayush/relabs/network/data/search/SearchResultNode.kt new file mode 100644 index 0000000..e8aedc7 --- /dev/null +++ b/app/src/main/java/io/aayush/relabs/network/data/search/SearchResultNode.kt @@ -0,0 +1,10 @@ +package io.aayush.relabs.network.data.search + +import io.aayush.relabs.network.data.common.Pagination +import io.aayush.relabs.network.data.node.Node + +data class SearchResultNode( + val search: Search = Search(), + val results: List = emptyList(), + val pagination: Pagination = Pagination() +) diff --git a/app/src/main/java/io/aayush/relabs/network/data/search/SearchResultThread.kt b/app/src/main/java/io/aayush/relabs/network/data/search/SearchResultThread.kt new file mode 100644 index 0000000..d4f17fb --- /dev/null +++ b/app/src/main/java/io/aayush/relabs/network/data/search/SearchResultThread.kt @@ -0,0 +1,10 @@ +package io.aayush.relabs.network.data.search + +import io.aayush.relabs.network.data.common.Pagination +import io.aayush.relabs.network.data.thread.Thread + +data class SearchResultThread( + val search: Search = Search(), + val results: List = emptyList(), + val pagination: Pagination = Pagination() +) diff --git a/app/src/main/java/io/aayush/relabs/network/data/search/Type.kt b/app/src/main/java/io/aayush/relabs/network/data/search/Type.kt new file mode 100644 index 0000000..abcbcaa --- /dev/null +++ b/app/src/main/java/io/aayush/relabs/network/data/search/Type.kt @@ -0,0 +1,6 @@ +package io.aayush.relabs.network.data.search + +enum class Type(val value: String) { + NODE("node"), + THREAD("post") +} diff --git a/app/src/main/java/io/aayush/relabs/ui/screens/threadpreview/ThreadPreviewScreen.kt b/app/src/main/java/io/aayush/relabs/ui/screens/threadpreview/ThreadPreviewScreen.kt index feb798c..3f9cb0e 100644 --- a/app/src/main/java/io/aayush/relabs/ui/screens/threadpreview/ThreadPreviewScreen.kt +++ b/app/src/main/java/io/aayush/relabs/ui/screens/threadpreview/ThreadPreviewScreen.kt @@ -6,47 +6,112 @@ package io.aayush.relabs.ui.screens.threadpreview import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBar import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemContentType import androidx.paging.compose.itemKey import io.aayush.relabs.R import io.aayush.relabs.network.data.thread.State import io.aayush.relabs.network.data.thread.Thread -import io.aayush.relabs.ui.components.MainTopAppBar import io.aayush.relabs.ui.components.ThreadPreviewItem import io.aayush.relabs.ui.navigation.Screen import kotlinx.coroutines.launch @Composable -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) fun ThreadPreviewScreen( navHostController: NavHostController, viewModel: ThreadPreviewViewModel = hiltViewModel() ) { + + val searchResults = viewModel.searchResults.collectAsLazyPagingItems() + val shouldShowSearchResults by viewModel.shouldShowSearchResults.collectAsStateWithLifecycle() + val isSearching by viewModel.isSearching.collectAsStateWithLifecycle() + val searchText by viewModel.searchQuery.collectAsStateWithLifecycle() + Scaffold( modifier = Modifier.fillMaxSize(), topBar = { - MainTopAppBar(screen = Screen.ThreadPreview, navHostController = navHostController) + Box(modifier = Modifier.fillMaxWidth()) { + SearchBar( + modifier = Modifier.align(Alignment.Center), + query = searchText, + onQueryChange = viewModel::updateQuery, + onSearch = { viewModel.search(searchText) }, + active = isSearching, + onActiveChange = { + viewModel.isSearching.value = it + if (!it) viewModel.updateQuery("") + }, + placeholder = { + Text(text = stringResource(id = R.string.search_hint_threads)) + }, + leadingIcon = { + if (isSearching) { + IconButton( + onClick = { + viewModel.isSearching.value = false + viewModel.updateQuery("") + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "" + ) + } + } else { + Icon(imageVector = Icons.Default.Search, contentDescription = "") + } + }, + trailingIcon = { + if (isSearching) { + IconButton( + onClick = { viewModel.updateQuery("") }, + enabled = searchText.isNotBlank() + ) { + Icon(imageVector = Icons.Default.Close, contentDescription = "") + } + } + } + ) { + if (shouldShowSearchResults) { + ThreadItems(currentThreads = searchResults, navHostController = navHostController) + } + } + } } ) { val tabData = listOf(R.string.watched, R.string.whats_new) @@ -78,48 +143,53 @@ fun ThreadPreviewScreen( } } HorizontalPager(state = pagerState) { - LazyColumn(modifier = Modifier.fillMaxHeight()) { - val currentThreads = when (it) { - 0 -> watchedThreads - else -> trendingThreads - } - when (currentThreads.loadState.refresh) { - is LoadState.Error -> {} - is LoadState.Loading -> { - items(20) { - ThreadPreviewItem( - modifier = Modifier.padding(10.dp), - loading = true - ) - } - } + val currentThreads = when (it) { + 0 -> watchedThreads + else -> trendingThreads + } + ThreadItems(currentThreads = currentThreads, navHostController = navHostController) + } + } + } +} + +@Composable +private fun ThreadItems(currentThreads: LazyPagingItems, navHostController: NavHostController) { + LazyColumn(modifier = Modifier.fillMaxHeight()) { + when (currentThreads.loadState.refresh) { + is LoadState.Error -> {} + is LoadState.Loading -> { + items(20) { + ThreadPreviewItem( + modifier = Modifier.padding(10.dp), + loading = true + ) + } + } - else -> { - items( - count = currentThreads.itemCount, - key = currentThreads.itemKey { t -> t.id }, - contentType = currentThreads.itemContentType { Thread::class.java }, - ) { index -> - val thread = currentThreads[index] ?: return@items - if (thread.state != State.VISIBLE) return@items + else -> { + items( + count = currentThreads.itemCount, + key = currentThreads.itemKey { t -> t.id }, + contentType = currentThreads.itemContentType { Thread::class.java }, + ) { index -> + val thread = currentThreads[index] ?: return@items + if (thread.state != State.VISIBLE) return@items - ThreadPreviewItem( - modifier = Modifier.padding(10.dp), - avatarURL = thread.user?.avatar?.data?.medium ?: String(), - title = thread.title, - author = thread.user?.username, - totalReplies = thread.reply_count, - views = thread.view_count, - lastReplyDate = thread.last_post_at.long, - forum = thread.node.title, - unread = thread.isUnread, - onClicked = { - navHostController.navigate(Screen.Thread.withID(thread.id)) - } - ) - } + ThreadPreviewItem( + modifier = Modifier.padding(10.dp), + avatarURL = thread.user?.avatar?.data?.medium ?: String(), + title = thread.title, + author = thread.user?.username, + totalReplies = thread.reply_count, + views = thread.view_count, + lastReplyDate = thread.last_post_at.long, + forum = thread.node.title, + unread = thread.isUnread, + onClicked = { + navHostController.navigate(Screen.Thread.withID(thread.id)) } - } + ) } } } diff --git a/app/src/main/java/io/aayush/relabs/ui/screens/threadpreview/ThreadPreviewViewModel.kt b/app/src/main/java/io/aayush/relabs/ui/screens/threadpreview/ThreadPreviewViewModel.kt index 4244070..05f5ead 100644 --- a/app/src/main/java/io/aayush/relabs/ui/screens/threadpreview/ThreadPreviewViewModel.kt +++ b/app/src/main/java/io/aayush/relabs/ui/screens/threadpreview/ThreadPreviewViewModel.kt @@ -18,6 +18,17 @@ class ThreadPreviewViewModel @Inject constructor( private val xdaRepository: XDARepository ) : ViewModel() { + private val _searchResults = MutableStateFlow>(PagingData.empty()) + val searchResults = _searchResults.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery = _searchQuery.asStateFlow() + + private val _shouldShowSearchResults = MutableStateFlow(false) + val shouldShowSearchResults = _shouldShowSearchResults.asStateFlow() + + val isSearching = MutableStateFlow(false) + private val _trendingThreads = MutableStateFlow>(PagingData.empty()) val trendingThreads = _trendingThreads.asStateFlow() @@ -50,4 +61,23 @@ class ThreadPreviewViewModel @Inject constructor( } } } + + fun updateQuery(query: String) { + _searchQuery.value = query + if (query.isBlank()) _shouldShowSearchResults.value = false + } + + fun search(query: String) { + _searchQuery.value = query + _shouldShowSearchResults.value = query.isNotBlank() + + viewModelScope.launch { + xdaRepository.getSearchResultsForThreads(query) + .distinctUntilChanged() + .cachedIn(viewModelScope) + .collect { + _searchResults.value = it + } + } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5571702..f69cf62 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -44,6 +44,7 @@ Check out this post on XDA forum:\n\n%1$s Added to quote list! Removed from quote list! + Search XDA for threads Reply