Merge pull request #1817 from omnivore-app/feature/android-filters

Library Filters - Android
This commit is contained in:
Satindar Dhillon 2023-02-21 09:55:18 -08:00 committed by GitHub
commit b54733e92e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 572 additions and 220 deletions

View file

@ -17,8 +17,8 @@ android {
applicationId "app.omnivore.omnivore"
minSdk 26
targetSdk 33
versionCode 19
versionName "0.0.19"
versionCode 26
versionName "0.0.26"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

File diff suppressed because one or more lines are too long

View file

@ -1,10 +1,7 @@
package app.omnivore.omnivore
import app.omnivore.omnivore.ui.reader.WebFont
object Constants {
const val apiURL = BuildConfig.OMNIVORE_API_URL
const val webURL = BuildConfig.OMNIVORE_WEB_URL
const val dataStoreName = "omnivore-datastore"
}
@ -18,6 +15,8 @@ object DatastoreKeys {
const val preferredWebMaxWidthPercentage = "preferredWebMaxWidthPercentage"
const val preferredWebFontFamily = "preferredWebFontFamily"
const val prefersWebHighContrastText = "prefersWebHighContrastText"
const val lastUsedSavedItemFilter = "lastUsedSavedItemFilter"
const val lastUsedSavedItemSortFilter = "lastUsedSavedItemSortFilter"
}
object AppleConstants {

View file

@ -15,7 +15,6 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import app.omnivore.omnivore.ui.auth.LoginViewModel
import app.omnivore.omnivore.ui.library.LibraryViewModel
import app.omnivore.omnivore.ui.reader.WebReaderViewModel
import app.omnivore.omnivore.ui.root.RootView
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
import com.pspdfkit.PSPDFKit
@ -32,7 +31,6 @@ class MainActivity : ComponentActivity() {
val loginViewModel: LoginViewModel by viewModels()
val libraryViewModel: LibraryViewModel by viewModels()
val webReaderViewModel: WebReaderViewModel by viewModels()
val context = this
@ -53,7 +51,7 @@ class MainActivity : ComponentActivity() {
.fillMaxSize()
.background(color = Color.Black)
) {
RootView(loginViewModel, libraryViewModel, webReaderViewModel)
RootView(loginViewModel, libraryViewModel)
}
}
}

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.room.Room
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.AppDatabase
import kotlinx.coroutines.*
import javax.inject.Inject
class DataService @Inject constructor(
@ -14,4 +15,10 @@ class DataService @Inject constructor(
context,
AppDatabase::class.java, "omnivore-database"
).build()
fun clearDatabase() {
CoroutineScope(Dispatchers.IO).launch {
db.clearAllTables()
}
}
}

View file

@ -0,0 +1,120 @@
package app.omnivore.omnivore.dataService
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import app.omnivore.omnivore.persistence.entities.SavedItemCardDataWithLabels
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.ui.library.SavedItemFilter
import app.omnivore.omnivore.ui.library.SavedItemSortFilter
fun DataService.libraryLiveData(
primaryFilter: SavedItemFilter,
sortFilter: SavedItemSortFilter,
labels: List<SavedItemLabel>
): LiveData<List<SavedItemCardDataWithLabels>> {
val mediatorLiveData = MediatorLiveData<List<SavedItemCardDataWithLabels>>()
val queryParams = LibraryLiveDataQueryParams.make(primaryFilter)
val libraryLiveData = when (sortFilter) {
SavedItemSortFilter.NEWEST -> db.savedItemDao().getLibraryLiveData(
archiveFilter = queryParams.archiveFilter
)
SavedItemSortFilter.OLDEST -> db.savedItemDao().getLibraryLiveDataSortedByOldest(
archiveFilter = queryParams.archiveFilter
)
SavedItemSortFilter.RECENTLY_READ -> db.savedItemDao().getLibraryLiveDataSortedByRecentlyRead(
archiveFilter = queryParams.archiveFilter
)
SavedItemSortFilter.RECENTLY_PUBLISHED -> db.savedItemDao().getLibraryLiveDataSortedByRecentlyPublished(
archiveFilter = queryParams.archiveFilter
)
}
mediatorLiveData.addSource(libraryLiveData) { result ->
when (primaryFilter) {
SavedItemFilter.INBOX -> {
mediatorLiveData.value = result
}
SavedItemFilter.READ_LATER -> {
mediatorLiveData.value = result.filter { item ->
!item.labels.any { it.name.lowercase() == "newsletter" }
}
}
SavedItemFilter.NEWSLETTERS -> {
mediatorLiveData.value = result.filter { item ->
item.labels.any { it.name.lowercase() == "newsletter" }
}
}
SavedItemFilter.RECOMMENDED -> {
mediatorLiveData.value = result // TODO: "recommendations.@count > 0"
}
SavedItemFilter.ALL -> {
mediatorLiveData.value = result
}
SavedItemFilter.ARCHIVED -> {
mediatorLiveData.value = result
}
SavedItemFilter.HAS_HIGHLIGHTS -> {
mediatorLiveData.value = result // TODO: "highlights.@count > 0"
}
SavedItemFilter.FILES -> {
mediatorLiveData.value = result.filter { item ->
item.cardData.contentReader == "PDF"
}
}
}
}
return mediatorLiveData
}
private data class LibraryLiveDataQueryParams(
val archiveFilter: Int
) {
companion object {
fun make(savedItemFilter: SavedItemFilter): LibraryLiveDataQueryParams {
return when (savedItemFilter) {
SavedItemFilter.INBOX -> {
LibraryLiveDataQueryParams(
archiveFilter = 1, // Filter out items marked as archive
)
}
SavedItemFilter.READ_LATER -> {
LibraryLiveDataQueryParams(
archiveFilter = 1, // Filter out items marked as archive
)
}
SavedItemFilter.NEWSLETTERS -> {
LibraryLiveDataQueryParams(
archiveFilter = 1, // Filter out items marked as archive
)
}
SavedItemFilter.RECOMMENDED -> {
LibraryLiveDataQueryParams(
archiveFilter = 1, // Filter out items marked as archive
)
}
SavedItemFilter.ALL -> {
LibraryLiveDataQueryParams(
archiveFilter = 2, // Don't filter anything out (2 will not match anything)
)
}
SavedItemFilter.ARCHIVED -> {
LibraryLiveDataQueryParams(
archiveFilter = 0, // Filter out items not marked as archived
)
}
SavedItemFilter.HAS_HIGHLIGHTS -> {
LibraryLiveDataQueryParams(
archiveFilter = 1, // Filter out items marked as archive
)
}
SavedItemFilter.FILES -> {
LibraryLiveDataQueryParams(
archiveFilter = 1, // Filter out items marked as archive
)
}
}
}
}
}

View file

@ -3,6 +3,9 @@ package app.omnivore.omnivore.persistence.entities
import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.room.*
import app.omnivore.omnivore.BuildConfig
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.ui.library.SavedItemSortFilter
import java.util.*
@Entity
@ -86,9 +89,6 @@ data class SavedItemCardData(
@Dao
interface SavedItemDao {
@Query("SELECT savedItemId, slug, publisherURLString, title, author, imageURLString, isArchived, pageURLString, contentReader FROM SavedItem ORDER BY savedAt DESC")
fun getLibraryLiveData(): LiveData<List<SavedItemCardData>>
@Query("SELECT * FROM savedItem")
fun getAll(): List<SavedItem>
@ -114,6 +114,43 @@ interface SavedItemDao {
fun update(savedItem: SavedItem)
@Transaction
@Query("SELECT savedItemId, slug, publisherURLString, title, author, imageURLString, isArchived, pageURLString, contentReader FROM SavedItem ORDER BY savedAt DESC")
fun getLibraryLiveDataWithLabels(): LiveData<List<SavedItemCardDataWithLabels>>
@Query(
"SELECT ${SavedItemQueryConstants.columns} " +
"FROM SavedItem " +
"WHERE serverSyncStatus != 2 AND isArchived != :archiveFilter " +
"ORDER BY savedAt DESC"
)
fun getLibraryLiveData(archiveFilter: Int): LiveData<List<SavedItemCardDataWithLabels>>
@Transaction
@Query(
"SELECT ${SavedItemQueryConstants.columns} " +
"FROM SavedItem " +
"WHERE serverSyncStatus != 2 AND isArchived != :archiveFilter " +
"ORDER BY savedAt ASC"
)
fun getLibraryLiveDataSortedByOldest(archiveFilter: Int): LiveData<List<SavedItemCardDataWithLabels>>
@Transaction
@Query(
"SELECT ${SavedItemQueryConstants.columns} " +
"FROM SavedItem " +
"WHERE serverSyncStatus != 2 AND isArchived != :archiveFilter " +
"ORDER BY readAt DESC, savedAt DESC"
)
fun getLibraryLiveDataSortedByRecentlyRead(archiveFilter: Int): LiveData<List<SavedItemCardDataWithLabels>>
@Transaction
@Query(
"SELECT ${SavedItemQueryConstants.columns} " +
"FROM SavedItem " +
"WHERE serverSyncStatus != 2 AND isArchived != :archiveFilter " +
"ORDER BY publishDate DESC"
)
fun getLibraryLiveDataSortedByRecentlyPublished(archiveFilter: Int): LiveData<List<SavedItemCardDataWithLabels>>
}
object SavedItemQueryConstants {
const val columns = "savedItemId, slug, publisherURLString, title, author, imageURLString, isArchived, pageURLString, contentReader "
}

View file

@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.*
import app.omnivore.omnivore.*
import app.omnivore.omnivore.dataService.DataService
import app.omnivore.omnivore.graphql.generated.ValidateUsernameQuery
import app.omnivore.omnivore.networking.Networker
import app.omnivore.omnivore.networking.viewer
@ -14,11 +15,8 @@ import com.google.android.gms.common.api.ApiException
import com.google.android.gms.tasks.Task
import dagger.hilt.android.lifecycle.HiltViewModel
import io.intercom.android.sdk.Intercom
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.regex.Pattern
import javax.inject.Inject
@ -38,7 +36,8 @@ data class PendingEmailUserCreds(
class LoginViewModel @Inject constructor(
private val datastoreRepo: DatastoreRepository,
private val eventTracker: EventTracker,
private val networker: Networker
private val networker: Networker,
private val dataService: DataService
): ViewModel() {
private var validateUsernameJob: Job? = null
@ -218,7 +217,7 @@ class LoginViewModel @Inject constructor(
isLoading = false
if (result.errorBody() != null) {
errorMessage = "Something went wrong. Please check your and try again"
errorMessage = "Something went wrong. Please check your entries and try again"
} else {
pendingEmailUserCreds = PendingEmailUserCreds(email, password)
}
@ -271,6 +270,7 @@ class LoginViewModel @Inject constructor(
fun logout() {
viewModelScope.launch {
datastoreRepo.clear()
dataService.clearDatabase()
Intercom.client().logout()
}
}

View file

@ -0,0 +1,65 @@
package app.omnivore.omnivore.ui.library
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
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.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryFilterBar(viewModel: LibraryViewModel) {
var isSavedItemFilterMenuExpanded by remember { mutableStateOf(false) }
val activeSavedItemFilter: SavedItemFilter by viewModel.appliedFilterLiveData.observeAsState(SavedItemFilter.INBOX)
var isSavedItemSortFilterMenuExpanded by remember { mutableStateOf(false) }
val activeSavedItemSortFilter: SavedItemSortFilter by viewModel.appliedSortFilterLiveData.observeAsState(SavedItemSortFilter.NEWEST)
Column {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
) {
AssistChip(
onClick = { isSavedItemFilterMenuExpanded = true },
label = { Text(activeSavedItemFilter.displayText) },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
contentDescription = "drop down button to change primary library filter"
)
},
modifier = Modifier.padding(end = 6.dp)
)
AssistChip(
onClick = { isSavedItemSortFilterMenuExpanded = true },
label = { Text(activeSavedItemSortFilter.displayText) },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
contentDescription = "drop down button to change library sort order"
)
},
modifier = Modifier.padding(end = 6.dp)
)
}
SavedItemFilterContextMenu(
isExpanded = isSavedItemFilterMenuExpanded,
onDismiss = { isSavedItemFilterMenuExpanded = false },
actionHandler = { viewModel.updateSavedItemFilter(it) }
)
SavedItemSortFilterContextMenu(
isExpanded = isSavedItemSortFilterMenuExpanded,
onDismiss = { isSavedItemSortFilterMenuExpanded = false },
actionHandler = { viewModel.updateSavedItemSortFilter(it) }
)
}
}

View file

@ -18,14 +18,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import app.omnivore.omnivore.Routes
import app.omnivore.omnivore.persistence.entities.SavedItemAndSavedItemLabelCrossRef
import app.omnivore.omnivore.persistence.entities.SavedItemCardData
import app.omnivore.omnivore.persistence.entities.SavedItemCardDataWithLabels
import app.omnivore.omnivore.ui.savedItemViews.SavedItemCard
import app.omnivore.omnivore.ui.reader.PDFReaderActivity
import app.omnivore.omnivore.ui.reader.WebReaderLoadingContainerActivity
import kotlinx.coroutines.flow.distinctUntilChanged
@ -35,20 +33,16 @@ fun LibraryView(
libraryViewModel: LibraryViewModel,
navController: NavHostController
) {
val searchText: String by libraryViewModel.searchTextLiveData.observeAsState("")
Scaffold(
topBar = {
SearchBar(
searchText = searchText,
onSearchTextChanged = { libraryViewModel.updateSearchText(it) },
libraryViewModel = libraryViewModel,
onSettingsIconClick = { navController.navigate(Routes.Settings.route) }
)
}
) { paddingValues ->
LibraryViewContent(
libraryViewModel,
navController,
modifier = Modifier
.padding(
top = paddingValues.calculateTopPadding(),
@ -60,11 +54,7 @@ fun LibraryView(
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun LibraryViewContent(
libraryViewModel: LibraryViewModel,
navController: NavHostController,
modifier: Modifier
) {
fun LibraryViewContent(libraryViewModel: LibraryViewModel, modifier: Modifier) {
val context = LocalContext.current
val listState = rememberLazyListState()
@ -91,17 +81,19 @@ fun LibraryViewContent(
.fillMaxSize()
.padding(horizontal = 6.dp)
) {
items(if (searchText.isNotEmpty()) searchedCardsData else cardsData) { cardDataWithLabels ->
if (!libraryViewModel.showSearchField) {
item {
LibraryFilterBar(libraryViewModel)
}
}
items(if (libraryViewModel.showSearchField) searchedCardsData else cardsData) { cardDataWithLabels ->
SavedItemCard(
cardData = cardDataWithLabels.cardData,
onClickHandler = {
if (cardDataWithLabels.cardData.isPDF()) {
val intent = Intent(context, PDFReaderActivity::class.java)
intent.putExtra("SAVED_ITEM_SLUG", cardDataWithLabels.cardData.slug)
context.startActivity(intent)
} else {
navController.navigate("WebReader/${cardDataWithLabels.cardData.slug}")
}
val activityClass = if (cardDataWithLabels.cardData.isPDF()) PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java
val intent = Intent(context, activityClass)
intent.putExtra("SAVED_ITEM_SLUG", cardDataWithLabels.cardData.slug)
context.startActivity(intent)
},
actionHandler = { libraryViewModel.handleSavedItemAction(cardDataWithLabels.cardData.savedItemId, it) }
)

View file

@ -3,18 +3,22 @@ package app.omnivore.omnivore.ui.library
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.*
import app.omnivore.omnivore.dataService.*
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.SavedItemCardData
import app.omnivore.omnivore.persistence.entities.SavedItem
import app.omnivore.omnivore.persistence.entities.SavedItemCardDataWithLabels
import app.omnivore.omnivore.ui.reader.WebFont
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.*
import java.time.LocalDateTime
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
@ -33,9 +37,43 @@ class LibraryViewModel @Inject constructor(
// Live Data
val searchTextLiveData = MutableLiveData("")
val searchItemsLiveData = MutableLiveData<List<SavedItemCardDataWithLabels>>(listOf())
val itemsLiveData = dataService.db.savedItemDao().getLibraryLiveDataWithLabels()
private var itemsLiveDataInternal = dataService.libraryLiveData(SavedItemFilter.INBOX, SavedItemSortFilter.NEWEST, listOf())
val itemsLiveData = MediatorLiveData<List<SavedItemCardDataWithLabels>>()
val appliedFilterLiveData = MutableLiveData(SavedItemFilter.INBOX)
val appliedSortFilterLiveData = MutableLiveData(SavedItemSortFilter.NEWEST)
var isRefreshing by mutableStateOf(false)
var showSearchField by mutableStateOf(false)
var hasLoadedInitialFilters = false
fun loadInitialFilterValues() {
if (hasLoadedInitialFilters) { return }
hasLoadedInitialFilters = false
runBlocking {
datastoreRepo.getString(DatastoreKeys.lastUsedSavedItemFilter)?.let { str ->
try {
val filter = SavedItemFilter.values().first { it.rawValue == str }
appliedFilterLiveData.postValue(filter)
} catch (e: Exception) {
Log.d("error", "invalid filter value stored in datastore repo: $e")
}
}
datastoreRepo.getString(DatastoreKeys.lastUsedSavedItemSortFilter)?.let { str ->
try {
val filter = SavedItemSortFilter.values().first { it.rawValue == str }
appliedSortFilterLiveData.postValue(filter)
} catch (e: Exception) {
Log.d("error", "invalid sort filter value stored in datastore repo: $e")
}
}
}
viewModelScope.launch {
handleFilterChanges()
}
}
fun updateSearchText(text: String) {
searchTextLiveData.value = text
@ -52,13 +90,19 @@ class LibraryViewModel @Inject constructor(
load(true)
}
fun getLastSyncTime(): LocalDateTime? = runBlocking {
fun getLastSyncTime(): Instant? = runBlocking {
datastoreRepo.getString(DatastoreKeys.libraryLastSyncTimestamp)?.let {
LocalDateTime.parse(it)
try {
return@let Instant.parse(it)
} catch (e: Exception) {
return@let null
}
}
}
fun load(clearPreviousSearch: Boolean = false) {
loadInitialFilterValues()
viewModelScope.launch {
if (searchTextLiveData.value != "") {
performSearch(clearPreviousSearch)
@ -68,16 +112,41 @@ class LibraryViewModel @Inject constructor(
}
}
private suspend fun syncItems() {
val syncStart = LocalDateTime.now()
val lastSyncDate = getLastSyncTime() ?: LocalDateTime.MIN
CoroutineScope(Dispatchers.Main).launch {
isRefreshing = false
fun updateSavedItemFilter(filter: SavedItemFilter) {
viewModelScope.launch {
datastoreRepo.putString(DatastoreKeys.lastUsedSavedItemFilter, filter.rawValue)
appliedFilterLiveData.value = filter
handleFilterChanges()
}
}
fun updateSavedItemSortFilter(filter: SavedItemSortFilter) {
viewModelScope.launch {
datastoreRepo.putString(DatastoreKeys.lastUsedSavedItemSortFilter, filter.rawValue)
appliedSortFilterLiveData.value = filter
handleFilterChanges()
}
}
suspend fun handleFilterChanges() {
if (searchTextLiveData.value != "") {
performSearch(true)
} else if (appliedSortFilterLiveData.value != null && appliedFilterLiveData.value != null) {
itemsLiveDataInternal = dataService.libraryLiveData(appliedFilterLiveData.value!!, appliedSortFilterLiveData.value!!, listOf())
itemsLiveData.removeSource(itemsLiveDataInternal)
itemsLiveData.addSource(itemsLiveDataInternal, itemsLiveData::setValue)
}
}
private suspend fun syncItems() {
val syncStart = Instant.now()
val lastSyncDate = getLastSyncTime() ?: Instant.MIN
withContext(Dispatchers.IO) {
performItemSync(cursor = null, since = lastSyncDate.toString(), count = 0, startTime = syncStart.toString())
CoroutineScope(Dispatchers.Main).launch {
isRefreshing = false
}
}
}
@ -118,7 +187,7 @@ class LibraryViewModel @Inject constructor(
searchIdx += 1
// Execute the search
val searchResult = networker.typeaheadSearch(searchTextLiveData.value ?: "")
val searchResult = networker.typeaheadSearch(searchQueryString())
// Search results aren't guaranteed to return in order so this
// will discard old results that are returned while a user is typing.
@ -159,6 +228,19 @@ class LibraryViewModel @Inject constructor(
}
}
}
private fun searchQueryString(): String {
return searchTextLiveData.value ?: ""
// Unused code for typeahead search
// var query = "${appliedFilterLiveData.value?.queryString} ${appliedSortFilterLiveData.value?.queryString}"
// val searchText = searchTextLiveData.value ?: ""
//
// if (searchText.isNotEmpty()) {
// query += " $searchText"
// }
//
// return query
}
}
enum class SavedItemAction {

View file

@ -0,0 +1,48 @@
package app.omnivore.omnivore.ui.library
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
enum class SavedItemFilter(val displayText: String, val rawValue: String, val queryString: String) {
INBOX("Inbox", rawValue = "inbox", "in:inbox"),
READ_LATER("Read Later", "readlater", "in:inbox -label:Newsletter"),
NEWSLETTERS("Newsletters", "newsletters", "in:inbox label:Newsletter"),
RECOMMENDED("Recommended", "recommended", "recommendedBy:*"),
ALL("All", "all", "in:all"),
ARCHIVED("Archived", "archived", "in:archive"),
HAS_HIGHLIGHTS("Highlighted", "hasHighlights", "has:highlights"),
FILES("Files", "files", "type:file"),
}
@Composable
fun SavedItemFilterContextMenu(
isExpanded: Boolean,
onDismiss: () -> Unit,
actionHandler: (SavedItemFilter) -> Unit
) {
DropdownMenu(
expanded = isExpanded,
onDismissRequest = onDismiss
) {
// Displaying only a subset of filters until we figure out the Room DB queries (and labels)
// SavedItemFilter.values().forEach {
listOf(
SavedItemFilter.INBOX,
SavedItemFilter.READ_LATER,
SavedItemFilter.NEWSLETTERS,
SavedItemFilter.ALL,
SavedItemFilter.ARCHIVED,
SavedItemFilter.FILES
).forEach {
DropdownMenuItem(
text = { Text(it.displayText) },
onClick = {
actionHandler(it)
onDismiss()
}
)
}
}
}

View file

@ -0,0 +1,35 @@
package app.omnivore.omnivore.ui.library
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
enum class SavedItemSortFilter(val displayText: String, val rawValue: String, val queryString: String) {
NEWEST("Newest", rawValue = "newest", "sort:saved"),
OLDEST("Oldest", rawValue = "oldest", "sort:saved-ASC"),
RECENTLY_READ("Recently Read", rawValue = "recentlyRead", "sort:read"),
RECENTLY_PUBLISHED("Recently Published", rawValue = "recentlyPublished", "sort:published"),
}
@Composable
fun SavedItemSortFilterContextMenu(
isExpanded: Boolean,
onDismiss: () -> Unit,
actionHandler: (SavedItemSortFilter) -> Unit
) {
DropdownMenu(
expanded = isExpanded,
onDismissRequest = onDismiss
) {
SavedItemSortFilter.values().forEach {
DropdownMenuItem(
text = { Text(it.displayText) },
onClick = {
actionHandler(it)
onDismiss()
}
)
}
}
}

View file

@ -15,6 +15,7 @@ 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.runtime.livedata.observeAsState
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@ -27,16 +28,15 @@ import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchBar(
searchText: String,
onSearchTextChanged: (String) -> Unit,
libraryViewModel: LibraryViewModel,
onSettingsIconClick: () -> Unit
) {
var showSearchField by remember { mutableStateOf(searchText != "") }
val searchText: String by libraryViewModel.searchTextLiveData.observeAsState("")
SmallTopAppBar(
title = {
if (showSearchField) {
SearchField(searchText, onSearchTextChanged)
if (libraryViewModel.showSearchField) {
SearchField(searchText) { libraryViewModel.updateSearchText(it) }
} else {
Text("Library")
}
@ -45,18 +45,18 @@ fun SearchBar(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
actions = {
if (showSearchField) {
if (libraryViewModel.showSearchField) {
Text(
text = "Cancel",
modifier = Modifier
.clickable {
onSearchTextChanged("")
showSearchField = false
libraryViewModel.updateSearchText("")
libraryViewModel.showSearchField = false
}
.padding(horizontal = 6.dp)
)
} else {
IconButton(onClick = { showSearchField = true }) {
IconButton(onClick = { libraryViewModel.showSearchField = true }) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null

View file

@ -4,42 +4,28 @@ import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Rect
import android.util.Log
import android.view.*
import android.view.View.OnScrollChangeListener
import android.view.ViewTreeObserver.OnScrollChangedListener
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.save.SaveSheetActivityBase
import app.omnivore.omnivore.ui.savedItemViews.SavedItemContextMenu
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.*
import kotlin.math.roundToInt
@SuppressLint("SetJavaScriptEnabled")
@Composable
@ -81,7 +67,14 @@ fun WebReader(
settings.allowFileAccess = true
settings.domStorageEnabled = true
alpha = 0.0f
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
viewModel?.showNavBar()
view?.animate()?.alpha(1.0f)?.duration = 200
}
}
val javascriptInterface = AndroidWebKitMessenger { actionID, json ->
@ -99,7 +92,6 @@ fun WebReader(
}
"existingHighlightTap" -> {
val tapCoordinates = Gson().fromJson(json, TapCoordinates::class.java)
Log.d("wv", "receive existing highlight tap action: $tapCoordinates")
CoroutineScope(Dispatchers.Main).launch {
webReaderViewModel.hasTappedExistingHighlight = true
webReaderViewModel.lastTapCoordinates = tapCoordinates
@ -142,9 +134,14 @@ fun WebReader(
}
}
class OmnivoreWebView(context: Context) : WebView(context) {
class OmnivoreWebView(context: Context) : WebView(context), OnScrollChangeListener {
var viewModel: WebReaderViewModel? = null
var actionMode: ActionMode? = null
val density = resources.displayMetrics.density
init {
setOnScrollChangeListener(this)
}
private val actionModeCallback = object : ActionMode.Callback2() {
// Called when the action mode is created; startActionMode() was called
@ -229,12 +226,10 @@ class OmnivoreWebView(context: Context) : WebView(context) {
override fun onGetContentRect(mode: ActionMode?, view: View?, outRect: Rect?) {
Log.d("wv", "outRect: $outRect, View: $view")
if (viewModel?.lastTapCoordinates != null) {
val scrollYOffset = viewModel?.scrollState?.value ?: 0
val xValue = viewModel!!.lastTapCoordinates!!.tapX.toInt()
val yValue = viewModel!!.lastTapCoordinates!!.tapY.toInt() + scrollYOffset + (viewModel?.currentToolbarHeight ?: 0)
val xValue = (viewModel!!.lastTapCoordinates!!.tapX * density).toInt()
val yValue = (viewModel!!.lastTapCoordinates!!.tapY * density).toInt()
val rect = Rect(xValue, yValue, xValue, yValue)
Log.d("wvt", "scrollState: ${viewModel?.scrollState?.value}, bar height: ${viewModel?.currentToolbarHeight}")
Log.d("wvt", "setting rect based on last tapped rect: ${viewModel?.lastTapCoordinates.toString()}")
Log.d("wvt", "rect: $rect")
@ -262,6 +257,10 @@ class OmnivoreWebView(context: Context) : WebView(context) {
Log.d("wv", "startActionMode:type called")
return super.startActionMode(actionModeCallback, type)
}
override fun onScrollChange(view: View?, x: Int, y: Int, oldX: Int, oldY: Int) {
viewModel?.onScrollChange((oldY - y).toFloat())
}
}
class AndroidWebKitMessenger(val messageHandler: (String, String) -> Unit) {

View file

@ -2,20 +2,18 @@ package app.omnivore.omnivore.ui.reader
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
@ -26,11 +24,7 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.core.view.ViewCompat
@ -50,31 +44,13 @@ class WebReaderLoadingContainerActivity: ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val requestID = intent.getStringExtra("SAVED_ITEM_REQUEST_ID") ?: ""
val requestID = intent.getStringExtra("SAVED_ITEM_REQUEST_ID")
val slug = intent.getStringExtra("SAVED_ITEM_SLUG")
setContent {
val systemUiController = rememberSystemUiController()
val useDarkIcons = !isSystemInDarkTheme()
OmnivoreTheme {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = Color.Black)
.systemBarsPadding()
) {
if (viewModel.hasFetchError.value == true) {
Text("We were unable to fetch your content.")
} else {
WebReaderLoadingContainer(
requestID = requestID,
onLibraryIconTap = { startMainActivity() },
webReaderViewModel = viewModel
)
}
}
}
DisposableEffect(systemUiController, useDarkIcons) {
systemUiController.setSystemBarsColor(
color = Color.Black,
@ -83,6 +59,25 @@ class WebReaderLoadingContainerActivity: ComponentActivity() {
onDispose {}
}
OmnivoreTheme {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = Color.Black)
) {
if (viewModel.hasFetchError.value == true) {
Text("We were unable to fetch your content.")
} else {
WebReaderLoadingContainer(
requestID = requestID,
slug = slug,
onLibraryIconTap = if (requestID != null) { { startMainActivity() } } else null,
webReaderViewModel = viewModel
)
}
}
}
}
// animate the view up when keyboard appears
@ -111,54 +106,31 @@ fun WebReaderLoadingContainer(slug: String? = null, requestID: String? = null, o
val webReaderParams: WebReaderParams? by webReaderViewModel.webReaderParamsLiveData.observeAsState(null)
val annotation: String? by webReaderViewModel.annotationLiveData.observeAsState(null)
val shouldPopView: Boolean by webReaderViewModel.shouldPopViewLiveData.observeAsState(false)
val toolbarHeightPx: Float by webReaderViewModel.currentToolbarHeightLiveData.observeAsState(0.0f)
val maxToolbarHeight = 48.dp
val maxToolbarHeightPx = with(LocalDensity.current) { maxToolbarHeight.roundToPx().toFloat() }
val toolbarHeightPx = remember { mutableStateOf(maxToolbarHeightPx) }
val backgroundColor = if (isSystemInDarkTheme()) Color.Black else Color.White
webReaderViewModel.maxToolbarHeightPx = with(LocalDensity.current) { maxToolbarHeight.roundToPx().toFloat() }
webReaderViewModel.loadItem(slug = slug, requestID = requestID)
// Create a connection to the nested scroll system and listen to the scroll happening inside child Column
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newHeight = toolbarHeightPx.value + delta
toolbarHeightPx.value = newHeight.coerceIn(0f, maxToolbarHeightPx)
return Offset.Zero
}
}
}
if (webReaderParams == null) {
webReaderViewModel.loadItem(slug = slug, requestID = requestID)
}
if (webReaderParams != null) {
Box(
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(webReaderViewModel.scrollState)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.requiredHeight(height = maxToolbarHeight)
) {
}
WebReader(webReaderParams!!, webReaderViewModel.storedWebPreferences(isSystemInDarkTheme()), webReaderViewModel)
}
Box(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.background(color = backgroundColor)
) {
if (webReaderParams != null) {
WebReader(
webReaderParams!!,
webReaderViewModel.storedWebPreferences(isSystemInDarkTheme()),
webReaderViewModel
)
TopAppBar(
modifier = Modifier
.height(height = with(LocalDensity.current) {
webReaderViewModel.currentToolbarHeight = toolbarHeightPx.value.toInt()
toolbarHeightPx.value.roundToInt().toDp()
} ),
toolbarHeightPx.roundToInt().toDp()
}),
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
title = {},
actions = {
@ -186,7 +158,12 @@ fun WebReaderLoadingContainer(slug: String? = null, requestID: String? = null, o
isExpanded = isMenuExpanded,
isArchived = webReaderParams!!.item.isArchived,
onDismiss = { isMenuExpanded = false },
actionHandler = { webReaderViewModel.handleSavedItemAction(webReaderParams!!.item.savedItemId, it) }
actionHandler = {
webReaderViewModel.handleSavedItemAction(
webReaderParams!!.item.savedItemId,
it
)
}
)
}
)
@ -218,15 +195,5 @@ fun WebReaderLoadingContainer(slug: String? = null, requestID: String? = null, o
onBackPressedDispatcher?.onBackPressed()
}
}
} else {
Column(
verticalArrangement = Arrangement.SpaceAround,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
Text("Loading...", color = Color.White)
}
}
}

View file

@ -2,6 +2,10 @@ package app.omnivore.omnivore.ui.reader
import android.util.Log
import androidx.compose.foundation.ScrollState
import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -34,31 +38,46 @@ class WebReaderViewModel @Inject constructor(
): ViewModel() {
var lastJavascriptActionLoopUUID: UUID = UUID.randomUUID()
var javascriptDispatchQueue: MutableList<String> = mutableListOf()
var scrollState = ScrollState(0)
var currentToolbarHeight = 0
var maxToolbarHeightPx = 0.0f
val webReaderParamsLiveData = MutableLiveData<WebReaderParams?>(null)
val annotationLiveData = MutableLiveData<String?>(null)
val javascriptActionLoopUUIDLiveData = MutableLiveData(lastJavascriptActionLoopUUID)
val shouldPopViewLiveData = MutableLiveData<Boolean>(false)
val hasFetchError = MutableLiveData<Boolean>(false)
val shouldPopViewLiveData = MutableLiveData(false)
val hasFetchError = MutableLiveData(false)
val currentToolbarHeightLiveData = MutableLiveData(0.0f)
var hasTappedExistingHighlight = false
var lastTapCoordinates: TapCoordinates? = null
private var isLoading = false
fun loadItem(slug: String?, requestID: String?) {
if (isLoading || webReaderParamsLiveData.value != null) { return }
isLoading = true
Log.d("reader", "load item called")
viewModelScope.launch {
slug?.let { loadItemUsingSlug(it) }
requestID?.let { loadItemUsingRequestID(it) }
}
}
fun showNavBar() {
onScrollChange(maxToolbarHeightPx)
}
fun onScrollChange(delta: Float) {
val newHeight = (currentToolbarHeightLiveData.value ?: 0.0f) + delta
currentToolbarHeightLiveData.value = newHeight.coerceIn(0f, maxToolbarHeightPx)
}
private suspend fun loadItemUsingSlug(slug: String) {
val webReaderParams = loadItemFromServer(slug)
if (webReaderParams != null) {
Log.d("sync", "data loaded from server")
Log.d("reader", "data loaded from server")
webReaderParamsLiveData.postValue(webReaderParams)
isLoading = false
} else {
loadItemFromDB(slug)
}
@ -70,6 +89,7 @@ class WebReaderViewModel @Inject constructor(
if (webReaderParams != null && isSuccessful) {
webReaderParamsLiveData.postValue(webReaderParams)
isLoading = false
} else if (requestCount < 7) {
// delay then try again
delay(2000L)
@ -96,6 +116,7 @@ class WebReaderViewModel @Inject constructor(
Log.d("sync", "data loaded from db")
webReaderParamsLiveData.postValue(WebReaderParams(persistedItem.savedItem, articleContent))
}
isLoading = false
}
}
@ -191,16 +212,6 @@ class WebReaderViewModel @Inject constructor(
}
}
fun reset() {
shouldPopViewLiveData.postValue(false)
webReaderParamsLiveData.value = null
annotationLiveData.value = null
scrollState = ScrollState(0)
javascriptDispatchQueue = mutableListOf()
hasTappedExistingHighlight = false
lastTapCoordinates = null
}
fun resetJavascriptDispatchQueue() {
lastJavascriptActionLoopUUID = javascriptActionLoopUUIDLiveData.value ?: UUID.randomUUID()
javascriptDispatchQueue = mutableListOf()

View file

@ -18,14 +18,12 @@ import app.omnivore.omnivore.ui.auth.LoginViewModel
import app.omnivore.omnivore.ui.auth.WelcomeScreen
import app.omnivore.omnivore.ui.library.LibraryView
import app.omnivore.omnivore.ui.library.LibraryViewModel
import app.omnivore.omnivore.ui.reader.*
import com.google.accompanist.systemuicontroller.rememberSystemUiController
@Composable
fun RootView(
loginViewModel: LoginViewModel,
libraryViewModel: LibraryViewModel,
webReaderViewModel: WebReaderViewModel
libraryViewModel: LibraryViewModel
) {
val hasAuthToken: Boolean by loginViewModel.hasAuthTokenLiveData.observeAsState(false)
val systemUiController = rememberSystemUiController()
@ -47,8 +45,7 @@ fun RootView(
if (hasAuthToken) {
PrimaryNavigator(
loginViewModel = loginViewModel,
libraryViewModel = libraryViewModel,
webReaderViewModel = webReaderViewModel
libraryViewModel = libraryViewModel
)
} else {
WelcomeScreen(viewModel = loginViewModel)
@ -66,8 +63,7 @@ fun RootView(
@Composable
fun PrimaryNavigator(
loginViewModel: LoginViewModel,
libraryViewModel: LibraryViewModel,
webReaderViewModel: WebReaderViewModel
libraryViewModel: LibraryViewModel
) {
val navController = rememberNavController()
@ -79,15 +75,6 @@ fun PrimaryNavigator(
)
}
composable("WebReader/{slug}") {
webReaderViewModel.reset() // clear previously loaded item
WebReaderLoadingContainer(
it.arguments?.getString("slug") ?: "",
webReaderViewModel = webReaderViewModel
)
}
composable(Routes.Settings.route) {
SettingsView(loginViewModel = loginViewModel, navController = navController)
}

View file

@ -25,6 +25,7 @@ import kotlinx.coroutines.launch
fun SaveContent(viewModel: SaveViewModel, modalBottomSheetState: ModalBottomSheetState, modifier: Modifier) {
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
val enableReadNow = false
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Column(
@ -37,26 +38,28 @@ fun SaveContent(viewModel: SaveViewModel, modalBottomSheetState: ModalBottomShee
) {
Text(text = viewModel.message ?: "Saving")
Row {
Button(
onClick = {
coroutineScope.launch {
modalBottomSheetState.hide()
viewModel.clientRequestID?.let {
val intent = Intent(context, WebReaderLoadingContainerActivity::class.java)
intent.putExtra("SAVED_ITEM_REQUEST_ID", it)
context.startActivity(intent)
if (enableReadNow) {
Button(
onClick = {
coroutineScope.launch {
modalBottomSheetState.hide()
viewModel.clientRequestID?.let {
val intent = Intent(context, WebReaderLoadingContainerActivity::class.java)
intent.putExtra("SAVED_ITEM_REQUEST_ID", it)
context.startActivity(intent)
}
}
}
},
colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D),
backgroundColor = Color.White
)
) {
Text(text = "Read Now")
}
},
colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D),
backgroundColor = Color.White
)
) {
Text(text = "Read Now")
}
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(8.dp))
}
Button(
onClick = {
@ -69,10 +72,11 @@ fun SaveContent(viewModel: SaveViewModel, modalBottomSheetState: ModalBottomShee
backgroundColor = Color(0xffffd234)
)
) {
Text(text = "Read Later")
Text(text = if (enableReadNow) "Read Later" else "Dismiss")
}
}
}
}
}

File diff suppressed because one or more lines are too long

View file

@ -54,7 +54,7 @@ const App = () => {
applyStoredTheme(false)
document.addEventListener('updateLabels', (event) => {
console.log("updating labels: ", event.labels)
console.log('updating labels: ', event.labels)
setLabels(event.labels)
})
@ -65,6 +65,7 @@ const App = () => {
overflowY: 'auto',
height: '100%',
width: '100vw',
paddingTop: window.webkit ? 0 : '48px', // add 48px to android only
}}
>
<VStack

View file

@ -302,8 +302,8 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
}
const tapAttributes = {
tapX: event.screenX,
tapY: event.screenY,
tapX: event.clientX,
tapY: event.clientY,
}
window?.AndroidWebKitMessenger?.handleIdentifiableMessage(

View file

@ -15,8 +15,8 @@ export function useSelection(
const handleFinishTouch = useCallback(
async (mouseEvent) => {
const tapAttributes = {
tapX: mouseEvent.screenX,
tapY: mouseEvent.screenY,
tapX: mouseEvent.clientX,
tapY: mouseEvent.clientY,
}
window?.AndroidWebKitMessenger?.handleIdentifiableMessage(