mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1168 from omnivore-app/feature/android-search-bar
Add search bar to android home feed
This commit is contained in:
commit
16524a8b59
6 changed files with 221 additions and 58 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue