mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1817 from omnivore-app/feature/android-filters
Library Filters - Android
This commit is contained in:
commit
b54733e92e
23 changed files with 572 additions and 220 deletions
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 "
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue