Merge pull request #1168 from omnivore-app/feature/android-search-bar

Add search bar to android home feed
This commit is contained in:
Satindar Dhillon 2022-09-06 21:42:07 -07:00 committed by GitHub
commit 16524a8b59
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 221 additions and 58 deletions

View file

@ -8,7 +8,6 @@ import android.view.ViewGroup
import android.webkit.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.TopAppBar
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf

View file

@ -1,21 +1,16 @@
package app.omnivore.omnivore.ui.home
import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import app.omnivore.omnivore.Routes
@ -28,32 +23,27 @@ fun HomeView(
homeViewModel: HomeViewModel,
navController: NavHostController
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Home") },
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
actions = {
IconButton(onClick = { navController.navigate(Routes.Settings.route) }) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = null
)
}
}
)
}
) { paddingValues ->
HomeViewContent(
homeViewModel,
navController,
modifier = Modifier
.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding()
)
val searchText: String by homeViewModel.searchTextLiveData.observeAsState("")
Scaffold(
topBar = {
SearchBar(
searchText = searchText,
onSearchTextChanged = { homeViewModel.updateSearchText(it) },
onSettingsIconClick = { navController.navigate(Routes.Settings.route) }
)
}
) { paddingValues ->
HomeViewContent(
homeViewModel,
navController,
modifier = Modifier
.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding()
)
)
}
}
@Composable

View file

@ -1,26 +1,14 @@
package app.omnivore.omnivore.ui.home
import android.annotation.SuppressLint
import android.net.Uri
import android.view.ViewGroup
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import android.util.Log
import androidx.core.net.toUri
import androidx.lifecycle.*
import app.omnivore.omnivore.AppleConstants
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.Constants
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.DatastoreRepository
import app.omnivore.omnivore.graphql.generated.SearchQuery
import app.omnivore.omnivore.ui.auth.AppleAuthDialog
import com.apollographql.apollo3.ApolloClient
import com.apollographql.apollo3.api.Optional
import dagger.hilt.android.lifecycle.HiltViewModel
@ -32,15 +20,41 @@ import javax.inject.Inject
class HomeViewModel @Inject constructor(
private val datastoreRepo: DatastoreRepository
): ViewModel() {
var cursor: String? = null
private var cursor: String? = null
private var items: List<LinkedItem> = listOf()
private var searchedItems: List<LinkedItem> = listOf()
// These are used to make sure we handle search result
// responses in the right order
private var searchIdx = 0
private var receivedIdx = 0
// Live Data
val searchTextLiveData = MutableLiveData<String>("")
val itemsLiveData = MutableLiveData<List<LinkedItem>>(listOf())
private fun getAuthToken(): String? = runBlocking {
datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken)
}
fun load() {
fun updateSearchText(text: String) {
searchTextLiveData.value = text
if (text == "") {
itemsLiveData.value = items
} else {
load(clearPreviousSearch = true)
}
}
fun load(clearPreviousSearch: Boolean = false) {
if (clearPreviousSearch) {
cursor = null
}
viewModelScope.launch {
val thisSearchIdx = searchIdx
searchIdx += 1
val authToken = getAuthToken()
val apolloClient = ApolloClient.Builder()
@ -52,12 +66,24 @@ class HomeViewModel @Inject constructor(
SearchQuery(
after = Optional.presentIfNotNull(cursor),
first = Optional.presentIfNotNull(15),
query = Optional.presentIfNotNull(searchQuery())
)
).execute()
cursor = response.data?.search?.onSearchSuccess?.pageInfo?.endCursor
val itemList = response.data?.search?.onSearchSuccess?.edges ?: listOf()
// Search results aren't guaranteed to return in order so this
// will discard old results that are returned while a user is typing.
// For example if a user types 'Canucks', often the search results
// for 'C' are returned after 'Canucks' because it takes the backend
// much longer to compute.
if (thisSearchIdx in 1..receivedIdx) {
return@launch
}
cursor = response.data?.search?.onSearchSuccess?.pageInfo?.endCursor
receivedIdx = thisSearchIdx
val itemList = response.data?.search?.onSearchSuccess?.edges ?: listOf()
val newItems = itemList.map {
LinkedItem(
id = it.node.id,
@ -74,9 +100,26 @@ class HomeViewModel @Inject constructor(
)
}
itemsLiveData.value = (itemsLiveData.value ?: listOf()).plus(newItems)
if (searchTextLiveData.value != "") {
val previousItems = if (clearPreviousSearch) listOf() else searchedItems
searchedItems = previousItems.plus(newItems)
itemsLiveData.value = searchedItems
} else {
items = items.plus(newItems)
itemsLiveData.value = items
}
}
}
private fun searchQuery(): String {
var query = "in:inbox sort:saved"
if (searchTextLiveData.value != "") {
query = query.plus(" ${searchTextLiveData.value}")
}
return query
}
}
public data class LinkedItem(

View file

@ -31,7 +31,7 @@ fun LinkedItemCard(item: LinkedItem, onClickHandler: () -> Unit) {
.clickable(onClick = onClickHandler)
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.weight(1f, fill = false)
.padding(end = 8.dp)

View file

@ -0,0 +1,130 @@
package app.omnivore.omnivore.ui.home
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchBar(
searchText: String,
onSearchTextChanged: (String) -> Unit,
onSettingsIconClick: () -> Unit
) {
var showSearchField by remember { mutableStateOf(searchText != "") }
SmallTopAppBar(
title = {
if (showSearchField) {
SearchField(searchText, onSearchTextChanged)
} else {
Text("Home")
}
},
colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
actions = {
if (showSearchField) {
Text(
text = "Cancel",
modifier = Modifier
.clickable {
onSearchTextChanged("")
showSearchField = false
}
.padding(horizontal = 6.dp)
)
} else {
IconButton(onClick = { showSearchField = true }) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null
)
}
IconButton(onClick = onSettingsIconClick) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = null
)
}
}
}
)
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun SearchField(
searchText: String,
onSearchTextChanged: (String) -> Unit
) {
var showClearButton by remember { mutableStateOf(false) }
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
Row {
TextField(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.onFocusChanged { focusState ->
showClearButton = (focusState.isFocused)
}
.focusRequester(focusRequester),
value = searchText,
onValueChange = onSearchTextChanged,
placeholder = {
Text(text = "Search")
},
trailingIcon = {
AnimatedVisibility(
visible = showClearButton,
enter = fadeIn(),
exit = fadeOut()
) {
IconButton(onClick = { onSearchTextChanged("") }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = null
)
}
}
},
maxLines = 1,
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
}),
)
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}

View file

@ -1,20 +1,19 @@
import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.*
import androidx.compose.material3.TopAppBarDefaults.smallTopAppBarColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import app.omnivore.omnivore.Routes
import app.omnivore.omnivore.ui.auth.LoginViewModel
import app.omnivore.omnivore.ui.home.HomeViewContent
import app.omnivore.omnivore.ui.home.HomeViewModel
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
@ -27,9 +26,11 @@ fun SettingsView(
) {
Scaffold(
topBar = {
TopAppBar(
SmallTopAppBar(
title = { Text("Settings") },
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
actions = {
IconButton(onClick = { navController.navigate(Routes.Home.route) }) {
Icon(