diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/AppleAuth.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/AppleAuth.kt index 13f9c9626..54c001f11 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/AppleAuth.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/AppleAuth.kt @@ -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 diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeView.kt index 849615447..58a8a0d53 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeView.kt @@ -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 diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeViewModel.kt index 49c91433e..5d4ab6074 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeViewModel.kt @@ -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 = listOf() + private var searchedItems: List = 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("") val itemsLiveData = MutableLiveData>(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( diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/LinkedItemCard.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/LinkedItemCard.kt index 71e4d41e4..eb6c42e37 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/LinkedItemCard.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/LinkedItemCard.kt @@ -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) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/SearchBar.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/SearchBar.kt new file mode 100644 index 000000000..c4d93badc --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/SearchBar.kt @@ -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() + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/settings/SettingsContent.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/settings/SettingsContent.kt index 6cd329ed5..be6a3db98 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/settings/SettingsContent.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/settings/SettingsContent.kt @@ -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(