diff --git a/android/Omnivore/app/build.gradle b/android/Omnivore/app/build.gradle index df7049536..c206af119 100644 --- a/android/Omnivore/app/build.gradle +++ b/android/Omnivore/app/build.gradle @@ -86,7 +86,7 @@ android { dependencies { def nav_version = "2.5.3" - implementation 'androidx.core:core-ktx:1.9.0' + implementation("androidx.core:core-ktx:1.12.0") implementation "androidx.compose.ui:ui:$compose_version" implementation "androidx.compose.material:material:$compose_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" @@ -111,6 +111,7 @@ dependencies { implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version") // ViewModel utilities for Compose implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version") + // LiveData implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version") implementation("androidx.compose.runtime:runtime-livedata:1.3.2") @@ -131,13 +132,13 @@ dependencies { implementation "androidx.security:security-crypto:1.0.0" implementation "androidx.datastore:datastore-preferences:1.0.0" - //Dagger - Hilt - implementation "com.google.dagger:hilt-android:$hilt_version" - kapt "com.google.dagger:hilt-compiler:$hilt_version" + // Dagger - Hilt + implementation("com.google.dagger:hilt-android:$hilt_version") + kapt("com.google.dagger:hilt-compiler:$hilt_version") implementation("com.apollographql.apollo3:apollo-runtime:3.8.2") - implementation 'androidx.compose.material3:material3:1.1.2' + implementation("androidx.compose.material3:material3:1.1.2") implementation 'androidx.compose.material3:material3-window-size-class:1.1.2' implementation 'com.google.android.gms:play-services-auth:20.4.0' diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryViewModel.kt index 17694b0f5..652be91d5 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryViewModel.kt @@ -3,18 +3,41 @@ package app.omnivore.omnivore.ui.library import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.* -import app.omnivore.omnivore.* -import app.omnivore.omnivore.dataService.* +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.omnivore.omnivore.DatastoreKeys +import app.omnivore.omnivore.DatastoreRepository +import app.omnivore.omnivore.R +import app.omnivore.omnivore.dataService.DataService +import app.omnivore.omnivore.dataService.archiveSavedItem +import app.omnivore.omnivore.dataService.deleteSavedItem +import app.omnivore.omnivore.dataService.fetchSavedItemContent +import app.omnivore.omnivore.dataService.isSavedItemContentStoredInDB +import app.omnivore.omnivore.dataService.librarySearch +import app.omnivore.omnivore.dataService.sync +import app.omnivore.omnivore.dataService.syncLabels +import app.omnivore.omnivore.dataService.syncOfflineItemsWithServerIfNeeded +import app.omnivore.omnivore.dataService.unarchiveSavedItem +import app.omnivore.omnivore.dataService.updateWebReadingProgress import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput -import app.omnivore.omnivore.network.* -import app.omnivore.omnivore.persistence.entities.* +import app.omnivore.omnivore.network.Networker +import app.omnivore.omnivore.network.createNewLabel +import app.omnivore.omnivore.persistence.entities.SavedItemLabel +import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights import app.omnivore.omnivore.ui.ResourceProvider import app.omnivore.omnivore.ui.setSavedItemLabels import com.apollographql.apollo3.api.Optional +import com.google.gson.Gson import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import java.time.Instant import javax.inject.Inject @@ -307,6 +330,20 @@ class LibraryViewModel @Inject constructor( currentItemLiveData.value = itemID bottomSheetState.value = LibraryBottomSheetState.EDIT } + + SavedItemAction.MarkRead -> { + viewModelScope.launch { + dataService.updateWebReadingProgress( + jsonString = Gson().toJson( + mapOf( + "id" to itemID, + "readingProgressPercent" to 100.0, + "readingProgressAnchorIndex" to 0 + ) + ) + ) + } + } } actionsMenuItemLiveData.postValue(null) } @@ -405,4 +442,5 @@ enum class SavedItemAction { Unarchive, EditLabels, EditInfo, + MarkRead } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/SearchViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/SearchViewModel.kt index f12f99e25..2fb7a1236 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/SearchViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/SearchViewModel.kt @@ -162,6 +162,8 @@ class SearchViewModel @Inject constructor( SavedItemAction.EditInfo -> { // TODO } + + SavedItemAction.MarkRead -> TODO() } actionsMenuItemLiveData.postValue(null) } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt index 635fc2625..9b6b4db2a 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt @@ -8,14 +8,29 @@ import android.net.Uri import android.util.Log import android.widget.Toast import androidx.core.content.ContextCompat.startActivity -import androidx.lifecycle.* +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope import app.omnivore.omnivore.DatastoreKeys import app.omnivore.omnivore.DatastoreRepository import app.omnivore.omnivore.EventTracker import app.omnivore.omnivore.R -import app.omnivore.omnivore.dataService.* +import app.omnivore.omnivore.dataService.DataService +import app.omnivore.omnivore.dataService.archiveSavedItem +import app.omnivore.omnivore.dataService.createWebHighlight +import app.omnivore.omnivore.dataService.deleteHighlightFromJSON +import app.omnivore.omnivore.dataService.deleteSavedItem +import app.omnivore.omnivore.dataService.mergeWebHighlights +import app.omnivore.omnivore.dataService.unarchiveSavedItem +import app.omnivore.omnivore.dataService.updateWebHighlight +import app.omnivore.omnivore.dataService.updateWebReadingProgress import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput -import app.omnivore.omnivore.network.* +import app.omnivore.omnivore.network.Networker +import app.omnivore.omnivore.network.createNewLabel +import app.omnivore.omnivore.network.saveUrl +import app.omnivore.omnivore.network.savedItem import app.omnivore.omnivore.persistence.entities.SavedItem import app.omnivore.omnivore.persistence.entities.SavedItemLabel import app.omnivore.omnivore.ui.components.HighlightColor @@ -24,298 +39,333 @@ import app.omnivore.omnivore.ui.setSavedItemLabels import com.apollographql.apollo3.api.Optional.Companion.presentIfNotNull import com.google.gson.Gson import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged -import java.util.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.util.UUID import javax.inject.Inject data class WebReaderParams( - val item: SavedItem, - val articleContent: ArticleContent, - val labels: List + val item: SavedItem, + val articleContent: ArticleContent, + val labels: List ) data class AnnotationWebViewMessage( - val annotation: String? + val annotation: String? ) -enum class Themes(val themeKey: String, - val backgroundColor: Long, - val foregroundColor: Long, - val scrollbarColor: Long) { - SYSTEM("System", 0xFF000000, 0xFF000000, 0xFF3A3939), - LIGHT("Light", 0xFFFFFFFF, 0xFF000000, 0xFF3A3939), - SEPIA("Sepia", 0xFFFBF0D9, 0xFF000000, 0xFF5F4B32), - DARK("Dark", 0xFF2F3030, 0xFFFFFFFF, 0xFFD8D7D7), - APOLLO("Apollo", 0xFF6A6968, 0xFFFFFFFF, 0xFFF3F3F3), - BLACK("Black", 0xFF000000, 0xFFFFFFFF, 0xFFFFFFFF), +enum class Themes( + val themeKey: String, + val backgroundColor: Long, + val foregroundColor: Long, + val scrollbarColor: Long +) { + SYSTEM("System", 0xFF000000, 0xFF000000, 0xFF3A3939), + LIGHT("Light", 0xFFFFFFFF, 0xFF000000, 0xFF3A3939), + SEPIA("Sepia", 0xFFFBF0D9, 0xFF000000, 0xFF5F4B32), + DARK("Dark", 0xFF2F3030, 0xFFFFFFFF, 0xFFD8D7D7), + APOLLO("Apollo", 0xFF6A6968, 0xFFFFFFFF, 0xFFF3F3F3), + BLACK("Black", 0xFF000000, 0xFFFFFFFF, 0xFFFFFFFF), } @HiltViewModel class WebReaderViewModel @Inject constructor( - private val datastoreRepo: DatastoreRepository, - private val dataService: DataService, - private val networker: Networker, - private val eventTracker: EventTracker, -): ViewModel() { - var lastJavascriptActionLoopUUID: UUID = UUID.randomUUID() - var javascriptDispatchQueue: MutableList = mutableListOf() - var maxToolbarHeightPx = 0.0f + private val datastoreRepo: DatastoreRepository, + private val dataService: DataService, + private val networker: Networker, + private val eventTracker: EventTracker, +) : ViewModel() { + var lastJavascriptActionLoopUUID: UUID = UUID.randomUUID() + var javascriptDispatchQueue: MutableList = mutableListOf() + var maxToolbarHeightPx = 0.0f - val webReaderParamsLiveData = MutableLiveData(null) - var annotation: String? = null - val javascriptActionLoopUUIDLiveData = MutableLiveData(lastJavascriptActionLoopUUID) - val shouldPopViewLiveData = MutableLiveData(false) - val hasFetchError = MutableLiveData(false) - val currentToolbarHeightLiveData = MutableLiveData(0.0f) - val savedItemLabelsLiveData = dataService.db.savedItemLabelDao().getSavedItemLabelsLiveData() + val webReaderParamsLiveData = MutableLiveData(null) + var annotation: String? = null + val javascriptActionLoopUUIDLiveData = MutableLiveData(lastJavascriptActionLoopUUID) + val shouldPopViewLiveData = MutableLiveData(false) + val hasFetchError = MutableLiveData(false) + val currentToolbarHeightLiveData = MutableLiveData(0.0f) + val savedItemLabelsLiveData = dataService.db.savedItemLabelDao().getSavedItemLabelsLiveData() - var currentLink: Uri? = null - val bottomSheetStateLiveData = MutableLiveData(BottomSheetState.NONE) + var currentLink: Uri? = null + val bottomSheetStateLiveData = MutableLiveData(BottomSheetState.NONE) - var hasTappedExistingHighlight = false - var lastTapCoordinates: TapCoordinates? = null - private var isLoading = false - private var slug: String? = null + var hasTappedExistingHighlight = false + var lastTapCoordinates: TapCoordinates? = null + private var isLoading = false + private var slug: String? = null - private val showHighlightColorPalette = MutableLiveData(false) - val highlightColor = MutableLiveData(HighlightColor()) + private val showHighlightColorPalette = MutableLiveData(false) + val highlightColor = MutableLiveData(HighlightColor()) - fun loadItem(slug: String?, requestID: String?) { - this.slug = slug - if (isLoading || webReaderParamsLiveData.value != null) { return } - isLoading = true - Log.d("reader", "load item called") + fun loadItem(slug: String?, requestID: String?) { + this.slug = slug + if (isLoading || webReaderParamsLiveData.value != null) { + return + } + isLoading = true + Log.d("reader", "load item called") - viewModelScope.launch { - slug?.let { loadItemUsingSlug(it) } - requestID?.let { loadItemUsingRequestID(it) } + viewModelScope.launch { + slug?.let { loadItemUsingSlug(it) } + requestID?.let { loadItemUsingRequestID(it) } + } } - } - fun showNavBar() { - onScrollChange(maxToolbarHeightPx) - } - - fun setBottomSheet(state: BottomSheetState) { - bottomSheetStateLiveData.postValue(state) - } - - fun resetBottomSheet() { - bottomSheetStateLiveData.postValue(BottomSheetState.NONE) - } - - fun showOpenLinkSheet(context: Context, uri: Uri) { - webReaderParamsLiveData.value?.let { - if (it.item.pageURLString == uri.toString()) { - openLink(context, uri) - } else { - currentLink = uri - bottomSheetStateLiveData.postValue(BottomSheetState.LINK) - } + fun showNavBar() { + onScrollChange(maxToolbarHeightPx) } - } - fun showShareLinkSheet(context: Context) { - webReaderParamsLiveData.value?.let { - val browserIntent = Intent(Intent.ACTION_SEND) - - browserIntent.setType("text/plain") - browserIntent.putExtra(Intent.EXTRA_TEXT, it.item.pageURLString) - browserIntent.putExtra(Intent.EXTRA_SUBJECT, it.item.pageURLString) - context.startActivity(browserIntent) + fun setBottomSheet(state: BottomSheetState) { + bottomSheetStateLiveData.postValue(state) } - } - fun openCurrentLink(context: Context) { - currentLink?.let { - openLink(context, it) + fun resetBottomSheet() { + bottomSheetStateLiveData.postValue(BottomSheetState.NONE) } - bottomSheetStateLiveData.postValue(BottomSheetState.NONE) - } - private fun openLink(context: Context, uri: Uri) { - val browserIntent = Intent(Intent.ACTION_VIEW, uri) - startActivity(context, browserIntent, null) - } - - fun saveCurrentLink(context: Context) { - currentLink?.let { - viewModelScope.launch { - val success = networker.saveUrl(it) - Toast.makeText(context, - if (success) - context.getString(R.string.web_reader_view_model_save_link_success) else - context.getString(R.string.web_reader_view_model_save_link_error), - Toast.LENGTH_SHORT).show() - } + fun showOpenLinkSheet(context: Context, uri: Uri) { + webReaderParamsLiveData.value?.let { + if (it.item.pageURLString == uri.toString()) { + openLink(context, uri) + } else { + currentLink = uri + bottomSheetStateLiveData.postValue(BottomSheetState.LINK) + } + } } - bottomSheetStateLiveData.postValue(BottomSheetState.NONE) - } - fun copyCurrentLink(context: Context) { - currentLink?.let { - val clip = ClipData.newPlainText("link", it.toString()) - val clipboard = - context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(clip) - clipboard.let { - clipboard.setPrimaryClip(clip) - Toast.makeText(context, - context.getString(R.string.web_reader_view_model_copy_link_success), - Toast.LENGTH_SHORT).show() - } + fun showShareLinkSheet(context: Context) { + webReaderParamsLiveData.value?.let { + val browserIntent = Intent(Intent.ACTION_SEND) + + browserIntent.setType("text/plain") + browserIntent.putExtra(Intent.EXTRA_TEXT, it.item.pageURLString) + browserIntent.putExtra(Intent.EXTRA_SUBJECT, it.item.pageURLString) + context.startActivity(browserIntent) + } } - bottomSheetStateLiveData.postValue(BottomSheetState.NONE) - } - fun onScrollChange(delta: Float) { - val newHeight = (currentToolbarHeightLiveData.value ?: 0.0f) + delta - currentToolbarHeightLiveData.value = newHeight.coerceIn(0f, maxToolbarHeightPx) - } - - private suspend fun loadItemUsingSlug(slug: String) { - loadItemFromDB(slug) - - val webReaderParams = loadItemFromServer(slug) - - if (webReaderParams != null) { - Log.d("reader", "data loaded from server") - eventTracker.track("link_read", - com.posthog.android.Properties() - .putValue("linkID", webReaderParams.item.savedItemId) - .putValue("slug", webReaderParams.item.slug) - .putValue("originalArticleURL", webReaderParams.item.pageURLString) - .putValue("loaded_from", "network") - ) - webReaderParamsLiveData.postValue(webReaderParams) - isLoading = false + fun openCurrentLink(context: Context) { + currentLink?.let { + openLink(context, it) + } + bottomSheetStateLiveData.postValue(BottomSheetState.NONE) } - } - private suspend fun loadItemUsingRequestID(requestID: String, requestCount: Int = 0) { - val webReaderParams = loadItemFromServer(requestID) - val isSuccessful = webReaderParams?.articleContent?.contentStatus == "SUCCEEDED" - - if (webReaderParams != null && isSuccessful) { - this.slug = webReaderParams.item.slug - eventTracker.track("link_read", - com.posthog.android.Properties() - .putValue("linkID", webReaderParams.item.savedItemId) - .putValue("slug", webReaderParams.item.slug) - .putValue("originalArticleURL", webReaderParams.item.pageURLString) - .putValue("loaded_from", "request_id") - ) - webReaderParamsLiveData.postValue(webReaderParams) - isLoading = false - } else if (requestCount < 7) { - // delay then try again - delay(2000L) - loadItemUsingRequestID(requestID = requestID, requestCount = requestCount + 1) - } else { - hasFetchError.postValue(true) + private fun openLink(context: Context, uri: Uri) { + val browserIntent = Intent(Intent.ACTION_VIEW, uri) + startActivity(context, browserIntent, null) } - } - private suspend fun loadItemFromDB(slug: String) { - withContext(Dispatchers.IO) { - val persistedItem = dataService.db.savedItemDao().getSavedItemWithLabelsAndHighlights(slug) + fun saveCurrentLink(context: Context) { + currentLink?.let { + viewModelScope.launch { + val success = networker.saveUrl(it) + Toast.makeText( + context, + if (success) + context.getString(R.string.web_reader_view_model_save_link_success) else + context.getString(R.string.web_reader_view_model_save_link_error), + Toast.LENGTH_SHORT + ).show() + } + } + bottomSheetStateLiveData.postValue(BottomSheetState.NONE) + } + + fun copyCurrentLink(context: Context) { + currentLink?.let { + val clip = ClipData.newPlainText("link", it.toString()) + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(clip) + clipboard.let { + clipboard.setPrimaryClip(clip) + Toast.makeText( + context, + context.getString(R.string.web_reader_view_model_copy_link_success), + Toast.LENGTH_SHORT + ).show() + } + } + bottomSheetStateLiveData.postValue(BottomSheetState.NONE) + } + + fun onScrollChange(delta: Float) { + val newHeight = (currentToolbarHeightLiveData.value ?: 0.0f) + delta + currentToolbarHeightLiveData.value = newHeight.coerceIn(0f, maxToolbarHeightPx) + } + + private suspend fun loadItemUsingSlug(slug: String) { + loadItemFromDB(slug) + + val webReaderParams = loadItemFromServer(slug) + + if (webReaderParams != null) { + Log.d("reader", "data loaded from server") + eventTracker.track( + "link_read", + com.posthog.android.Properties() + .putValue("linkID", webReaderParams.item.savedItemId) + .putValue("slug", webReaderParams.item.slug) + .putValue("originalArticleURL", webReaderParams.item.pageURLString) + .putValue("loaded_from", "network") + ) + webReaderParamsLiveData.postValue(webReaderParams) + isLoading = false + } + } + + private suspend fun loadItemUsingRequestID(requestID: String, requestCount: Int = 0) { + val webReaderParams = loadItemFromServer(requestID) + val isSuccessful = webReaderParams?.articleContent?.contentStatus == "SUCCEEDED" + + if (webReaderParams != null && isSuccessful) { + this.slug = webReaderParams.item.slug + eventTracker.track( + "link_read", + com.posthog.android.Properties() + .putValue("linkID", webReaderParams.item.savedItemId) + .putValue("slug", webReaderParams.item.slug) + .putValue("originalArticleURL", webReaderParams.item.pageURLString) + .putValue("loaded_from", "request_id") + ) + webReaderParamsLiveData.postValue(webReaderParams) + isLoading = false + } else if (requestCount < 7) { + // delay then try again + delay(2000L) + loadItemUsingRequestID(requestID = requestID, requestCount = requestCount + 1) + } else { + hasFetchError.postValue(true) + } + } + + private suspend fun loadItemFromDB(slug: String) { + withContext(Dispatchers.IO) { + val persistedItem = + dataService.db.savedItemDao().getSavedItemWithLabelsAndHighlights(slug) + + if (persistedItem?.savedItem?.content != null) { + val articleContent = ArticleContent( + title = persistedItem.savedItem.title, + htmlContent = persistedItem.savedItem.content, + highlights = persistedItem.highlights, + contentStatus = "SUCCEEDED", + objectID = "", + labelsJSONString = Gson().toJson(persistedItem.labels) + ) + + val webReaderParams = WebReaderParams( + persistedItem.savedItem, + articleContent, + persistedItem.labels + ) + + Log.d("sync", "data loaded from db") + eventTracker.track( + "link_read", + com.posthog.android.Properties() + .putValue("linkID", webReaderParams.item.savedItemId) + .putValue("slug", webReaderParams.item.slug) + .putValue("originalArticleURL", webReaderParams.item.pageURLString) + .putValue("loaded_from", "db") + ) + webReaderParamsLiveData.postValue(webReaderParams) + } + isLoading = false + } + } + + private suspend fun loadItemFromServer(slug: String): WebReaderParams? { + val articleQueryResult = networker.savedItem(slug) + + val article = articleQueryResult.item ?: return null - if (persistedItem?.savedItem?.content != null) { val articleContent = ArticleContent( - title = persistedItem.savedItem.title, - htmlContent = persistedItem.savedItem.content, - highlights = persistedItem.highlights, - contentStatus = "SUCCEEDED", - objectID = "", - labelsJSONString = Gson().toJson(persistedItem.labels) + title = article.title, + htmlContent = article.content ?: "", + highlights = articleQueryResult.highlights, + contentStatus = articleQueryResult.state, + objectID = "", + labelsJSONString = Gson().toJson(articleQueryResult.labels) ) - val webReaderParams = WebReaderParams( - persistedItem.savedItem, - articleContent, - persistedItem.labels - ) - - Log.d("sync", "data loaded from db") - eventTracker.track("link_read", - com.posthog.android.Properties() - .putValue("linkID", webReaderParams.item.savedItemId) - .putValue("slug", webReaderParams.item.slug) - .putValue("originalArticleURL", webReaderParams.item.pageURLString) - .putValue("loaded_from", "db") - ) - webReaderParamsLiveData.postValue(webReaderParams) - } - isLoading = false + return WebReaderParams(article, articleContent, articleQueryResult.labels) } - } - private suspend fun loadItemFromServer(slug: String): WebReaderParams? { - val articleQueryResult = networker.savedItem(slug) + fun handleSavedItemAction(itemID: String, action: SavedItemAction) { + when (action) { + SavedItemAction.Delete -> { + viewModelScope.launch { + dataService.deleteSavedItem(itemID) + popToLibraryView() + } + } - val article = articleQueryResult.item ?: return null + SavedItemAction.Archive -> { + viewModelScope.launch { + dataService.archiveSavedItem(itemID) + popToLibraryView() + } + } - val articleContent = ArticleContent( - title = article.title, - htmlContent = article.content ?: "", - highlights = articleQueryResult.highlights, - contentStatus = articleQueryResult.state, - objectID = "", - labelsJSONString = Gson().toJson(articleQueryResult.labels) - ) + SavedItemAction.Unarchive -> { + viewModelScope.launch { + dataService.unarchiveSavedItem(itemID) + popToLibraryView() + } + } - return WebReaderParams(article, articleContent, articleQueryResult.labels) - } + SavedItemAction.EditLabels -> { + bottomSheetStateLiveData.postValue(BottomSheetState.LABELS) + } - fun handleSavedItemAction(itemID: String, action: SavedItemAction) { - when (action) { - SavedItemAction.Delete -> { - viewModelScope.launch { - dataService.deleteSavedItem(itemID) - popToLibraryView() + SavedItemAction.EditInfo -> { + bottomSheetStateLiveData.postValue(BottomSheetState.EDIT_INFO) + } + + SavedItemAction.MarkRead -> { + viewModelScope.launch { + dataService.updateWebReadingProgress( + jsonString = Gson().toJson( + mapOf( + "id" to itemID, + "readingProgressPercent" to 100.0, + "readingProgressAnchorIndex" to 0 + ) + ) + ) + } + } } - } - SavedItemAction.Archive -> { - viewModelScope.launch { - dataService.archiveSavedItem(itemID) - popToLibraryView() + } + + private fun popToLibraryView() { + CoroutineScope(Dispatchers.Main).launch { + shouldPopViewLiveData.postValue(true) } - } - SavedItemAction.Unarchive -> { - viewModelScope.launch { - dataService.unarchiveSavedItem(itemID) - popToLibraryView() + } + + + fun showHighlightColorPalette() { + CoroutineScope(Dispatchers.Main).launch { + showHighlightColorPalette.postValue(true) } - } - SavedItemAction.EditLabels -> { - bottomSheetStateLiveData.postValue(BottomSheetState.LABELS) - } - SavedItemAction.EditInfo -> { - bottomSheetStateLiveData.postValue(BottomSheetState.EDIT_INFO) - } } - } - private fun popToLibraryView() { - CoroutineScope(Dispatchers.Main).launch { - shouldPopViewLiveData.postValue(true) + fun hideHighlightColorPalette() { + CoroutineScope(Dispatchers.Main).launch { + showHighlightColorPalette.postValue(false) + } } - } - - - fun showHighlightColorPalette() { - CoroutineScope(Dispatchers.Main).launch { - showHighlightColorPalette.postValue(true) - } - } - - fun hideHighlightColorPalette() { - CoroutineScope(Dispatchers.Main).launch { - showHighlightColorPalette.postValue(false) - } - } // fun setHighlightColor(color: HighlightColor) { // CoroutineScope(Dispatchers.Main).launch { @@ -323,243 +373,276 @@ class WebReaderViewModel @Inject constructor( // } // } - fun handleIncomingWebMessage(actionID: String, jsonString: String) { - Log.d("sync", "incoming change: ${actionID}: ${jsonString}") - when (actionID) { - "createHighlight" -> { - viewModelScope.launch { - dataService.createWebHighlight(jsonString, highlightColor.value?.name) + fun handleIncomingWebMessage(actionID: String, jsonString: String) { + Log.d("sync", "incoming change: ${actionID}: ${jsonString}") + when (actionID) { + "createHighlight" -> { + viewModelScope.launch { + dataService.createWebHighlight(jsonString, highlightColor.value?.name) + } + } + + "deleteHighlight" -> { + viewModelScope.launch { + dataService.deleteHighlightFromJSON(jsonString) + } + } + + "updateHighlight" -> { + viewModelScope.launch { + dataService.updateWebHighlight(jsonString) + } + } + + "articleReadingProgress" -> { + viewModelScope.launch { + dataService.updateWebReadingProgress(jsonString) + } + } + + "annotate" -> { + viewModelScope.launch { + val annotationStr = Gson() + .fromJson(jsonString, AnnotationWebViewMessage::class.java) + .annotation ?: "" + annotation = annotationStr + bottomSheetStateLiveData.postValue(BottomSheetState.HIGHLIGHTNOTE) + } + } + + "shareHighlight" -> { + // unimplemented + } + + "mergeHighlight" -> { + viewModelScope.launch { + dataService.mergeWebHighlights(jsonString) + } + } + + else -> { + Log.d("Loggo", "receive unrecognized action of $actionID with json: $jsonString") + } } - } - "deleteHighlight" -> { - viewModelScope.launch { - dataService.deleteHighlightFromJSON(jsonString) - } - } - "updateHighlight" -> { - viewModelScope.launch { - dataService.updateWebHighlight(jsonString) - } - } - "articleReadingProgress" -> { - viewModelScope.launch { - dataService.updateWebReadingProgress(jsonString) - } - } - "annotate" -> { - viewModelScope.launch { - val annotationStr = Gson() - .fromJson(jsonString, AnnotationWebViewMessage::class.java) - .annotation ?: "" - annotation = annotationStr - bottomSheetStateLiveData.postValue(BottomSheetState.HIGHLIGHTNOTE) - } - } - "shareHighlight" -> { - // unimplemented - } - "mergeHighlight" -> { - viewModelScope.launch { - dataService.mergeWebHighlights(jsonString) - } - } - else -> { - Log.d("Loggo", "receive unrecognized action of $actionID with json: $jsonString") - } - } - } - - fun resetJavascriptDispatchQueue() { - lastJavascriptActionLoopUUID = javascriptActionLoopUUIDLiveData.value ?: UUID.randomUUID() - javascriptDispatchQueue = mutableListOf() - } - - fun saveAnnotation(annotation: String) { - val jsonAnnotation = Gson().toJson(annotation) - val script = "var event = new Event('saveAnnotation');event.annotation = $jsonAnnotation;document.dispatchEvent(event);" - - Log.d("loggo", script) - - enqueueScript(script) - cancelAnnotationEdit() - } - - fun cancelAnnotation() { - val script = "var event = new Event('dismissHighlight');document.dispatchEvent(event);" - - enqueueScript(script) - cancelAnnotationEdit() - } - - private fun cancelAnnotationEdit() { - annotation = null - resetBottomSheet() - } - - private fun enqueueScript(javascript: String) { - javascriptDispatchQueue.add(javascript) - javascriptActionLoopUUIDLiveData.value = UUID.randomUUID() - } - - val currentThemeKey: LiveData = datastoreRepo - .themeKeyFlow - .distinctUntilChanged() - .asLiveData() - - fun storedWebPreferences(isDarkMode: Boolean): WebPreferences = runBlocking { - val storedFontSize = datastoreRepo.getInt(DatastoreKeys.preferredWebFontSize) - val storedLineHeight = datastoreRepo.getInt(DatastoreKeys.preferredWebLineHeight) - val storedMaxWidth = datastoreRepo.getInt(DatastoreKeys.preferredWebMaxWidthPercentage) - - val storedFontFamily = datastoreRepo.getString(DatastoreKeys.preferredWebFontFamily) ?: WebFont.SYSTEM.rawValue - val storedThemePreference = datastoreRepo.getString(DatastoreKeys.preferredTheme) ?: "System" - val storedWebFont = WebFont.values().firstOrNull { it.rawValue == storedFontFamily } ?: WebFont.values().first() - - val prefersHighContrastFont = datastoreRepo.getString(DatastoreKeys.prefersWebHighContrastText) == "true" - val prefersJustifyText = datastoreRepo.getString(DatastoreKeys.prefersJustifyText) == "true" - - WebPreferences( - textFontSize = storedFontSize ?: 12, - lineHeight = storedLineHeight ?: 150, - maxWidthPercentage = storedMaxWidth ?: 100, - themeKey = themeKey(isDarkMode, storedThemePreference), - storedThemePreference = storedThemePreference, - fontFamily = storedWebFont, - prefersHighContrastText = prefersHighContrastFont, - prefersJustifyText = prefersJustifyText - ) - } - - fun themeKey(isDarkMode: Boolean, storedThemePreference: String): String { - if (storedThemePreference == "System") { - return if (isDarkMode) "Black" else "Light" } - return storedThemePreference - } - - fun updateStoredThemePreference(newThemeKey: String) { - Log.d("theme", "Setting theme key: $newThemeKey") - - runBlocking { - datastoreRepo.putString(DatastoreKeys.preferredTheme, newThemeKey) + fun resetJavascriptDispatchQueue() { + lastJavascriptActionLoopUUID = javascriptActionLoopUUIDLiveData.value ?: UUID.randomUUID() + javascriptDispatchQueue = mutableListOf() } - val script = "var event = new Event('updateTheme');event.themeName = '$newThemeKey';document.dispatchEvent(event);" - enqueueScript(script) - } + fun saveAnnotation(annotation: String) { + val jsonAnnotation = Gson().toJson(annotation) + val script = + "var event = new Event('saveAnnotation');event.annotation = $jsonAnnotation;document.dispatchEvent(event);" - fun setFontSize(newFontSize: Int) { - runBlocking { - datastoreRepo.putInt(DatastoreKeys.preferredWebFontSize, newFontSize) - } - val script = "var event = new Event('updateFontSize');event.fontSize = '$newFontSize';document.dispatchEvent(event);" - enqueueScript(script) - } + Log.d("loggo", script) - fun setMaxWidthPercentage(newMaxWidthPercentageValue: Int) { - runBlocking { - datastoreRepo.putInt(DatastoreKeys.preferredWebMaxWidthPercentage, newMaxWidthPercentageValue) - } - val script = "var event = new Event('updateMaxWidthPercentage');event.maxWidthPercentage = '$newMaxWidthPercentageValue';document.dispatchEvent(event);" - enqueueScript(script) - } - - fun setLineHeight(newLineHeight: Int) { - runBlocking { - datastoreRepo.putInt(DatastoreKeys.preferredWebLineHeight, newLineHeight) - } - val script = "var event = new Event('updateLineHeight');event.lineHeight = '$newLineHeight';document.dispatchEvent(event);" - enqueueScript(script) - } - - fun updateHighContrastTextPreference(prefersHighContrastText: Boolean) { - runBlocking { - datastoreRepo.putString(DatastoreKeys.prefersWebHighContrastText, prefersHighContrastText.toString()) - } - val fontContrastValue = if (prefersHighContrastText) "high" else "normal" - val script = "var event = new Event('handleFontContrastChange');event.fontContrast = '$fontContrastValue';document.dispatchEvent(event);" - enqueueScript(script) - } - - fun updateJustifyText(justifyText: Boolean) { - runBlocking { - datastoreRepo.putString(DatastoreKeys.prefersJustifyText, justifyText.toString()) - } - val script = "var event = new Event('updateJustifyText');event.justifyText = $justifyText;document.dispatchEvent(event);" - enqueueScript(script) - } - - fun applyWebFont(font: WebFont) { - runBlocking { - datastoreRepo.putString(DatastoreKeys.preferredWebFontFamily, font.rawValue) + enqueueScript(script) + cancelAnnotationEdit() } - val script = "var event = new Event('updateFontFamily');event.fontFamily = '${font.rawValue}';document.dispatchEvent(event);" - enqueueScript(script) - } + fun cancelAnnotation() { + val script = "var event = new Event('dismissHighlight');document.dispatchEvent(event);" - fun updateSavedItemLabels(savedItemID: String, labels: List) { - viewModelScope.launch { - withContext(Dispatchers.IO) { + enqueueScript(script) + cancelAnnotationEdit() + } - setSavedItemLabels( - networker = networker, - dataService = dataService, - savedItemID = savedItemID, - labels = labels + private fun cancelAnnotationEdit() { + annotation = null + resetBottomSheet() + } + + private fun enqueueScript(javascript: String) { + javascriptDispatchQueue.add(javascript) + javascriptActionLoopUUIDLiveData.value = UUID.randomUUID() + } + + val currentThemeKey: LiveData = datastoreRepo + .themeKeyFlow + .distinctUntilChanged() + .asLiveData() + + fun storedWebPreferences(isDarkMode: Boolean): WebPreferences = runBlocking { + val storedFontSize = datastoreRepo.getInt(DatastoreKeys.preferredWebFontSize) + val storedLineHeight = datastoreRepo.getInt(DatastoreKeys.preferredWebLineHeight) + val storedMaxWidth = datastoreRepo.getInt(DatastoreKeys.preferredWebMaxWidthPercentage) + + val storedFontFamily = + datastoreRepo.getString(DatastoreKeys.preferredWebFontFamily) ?: WebFont.SYSTEM.rawValue + val storedThemePreference = + datastoreRepo.getString(DatastoreKeys.preferredTheme) ?: "System" + val storedWebFont = + WebFont.values().firstOrNull { it.rawValue == storedFontFamily } ?: WebFont.values() + .first() + + val prefersHighContrastFont = + datastoreRepo.getString(DatastoreKeys.prefersWebHighContrastText) == "true" + val prefersJustifyText = datastoreRepo.getString(DatastoreKeys.prefersJustifyText) == "true" + + WebPreferences( + textFontSize = storedFontSize ?: 12, + lineHeight = storedLineHeight ?: 150, + maxWidthPercentage = storedMaxWidth ?: 100, + themeKey = themeKey(isDarkMode, storedThemePreference), + storedThemePreference = storedThemePreference, + fontFamily = storedWebFont, + prefersHighContrastText = prefersHighContrastFont, + prefersJustifyText = prefersJustifyText ) + } - slug?.let { - loadItemFromDB(it) + fun themeKey(isDarkMode: Boolean, storedThemePreference: String): String { + if (storedThemePreference == "System") { + return if (isDarkMode) "Black" else "Light" } - // Send labels to webview - val script = "var event = new Event('updateLabels');event.labels = ${Gson().toJson(labels)};document.dispatchEvent(event);" + return storedThemePreference + } + + fun updateStoredThemePreference(newThemeKey: String) { + Log.d("theme", "Setting theme key: $newThemeKey") + + runBlocking { + datastoreRepo.putString(DatastoreKeys.preferredTheme, newThemeKey) + } + + val script = + "var event = new Event('updateTheme');event.themeName = '$newThemeKey';document.dispatchEvent(event);" + enqueueScript(script) + } + + fun setFontSize(newFontSize: Int) { + runBlocking { + datastoreRepo.putInt(DatastoreKeys.preferredWebFontSize, newFontSize) + } + val script = + "var event = new Event('updateFontSize');event.fontSize = '$newFontSize';document.dispatchEvent(event);" + enqueueScript(script) + } + + fun setMaxWidthPercentage(newMaxWidthPercentageValue: Int) { + runBlocking { + datastoreRepo.putInt( + DatastoreKeys.preferredWebMaxWidthPercentage, + newMaxWidthPercentageValue + ) + } + val script = + "var event = new Event('updateMaxWidthPercentage');event.maxWidthPercentage = '$newMaxWidthPercentageValue';document.dispatchEvent(event);" + enqueueScript(script) + } + + fun setLineHeight(newLineHeight: Int) { + runBlocking { + datastoreRepo.putInt(DatastoreKeys.preferredWebLineHeight, newLineHeight) + } + val script = + "var event = new Event('updateLineHeight');event.lineHeight = '$newLineHeight';document.dispatchEvent(event);" + enqueueScript(script) + } + + fun updateHighContrastTextPreference(prefersHighContrastText: Boolean) { + runBlocking { + datastoreRepo.putString( + DatastoreKeys.prefersWebHighContrastText, + prefersHighContrastText.toString() + ) + } + val fontContrastValue = if (prefersHighContrastText) "high" else "normal" + val script = + "var event = new Event('handleFontContrastChange');event.fontContrast = '$fontContrastValue';document.dispatchEvent(event);" + enqueueScript(script) + } + + fun updateJustifyText(justifyText: Boolean) { + runBlocking { + datastoreRepo.putString(DatastoreKeys.prefersJustifyText, justifyText.toString()) + } + val script = + "var event = new Event('updateJustifyText');event.justifyText = $justifyText;document.dispatchEvent(event);" + enqueueScript(script) + } + + fun applyWebFont(font: WebFont) { + runBlocking { + datastoreRepo.putString(DatastoreKeys.preferredWebFontFamily, font.rawValue) + } + + val script = + "var event = new Event('updateFontFamily');event.fontFamily = '${font.rawValue}';document.dispatchEvent(event);" + enqueueScript(script) + } + + fun updateSavedItemLabels(savedItemID: String, labels: List) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + + setSavedItemLabels( + networker = networker, + dataService = dataService, + savedItemID = savedItemID, + labels = labels + ) + + slug?.let { + loadItemFromDB(it) + } + + // Send labels to webview + val script = + "var event = new Event('updateLabels');event.labels = ${Gson().toJson(labels)};document.dispatchEvent(event);" + CoroutineScope(Dispatchers.Main).launch { + enqueueScript(script) + } + } + } + } + + fun updateItemTitle() { + viewModelScope.launch { + slug?.let { + loadItemFromDB(it) + } + + webReaderParamsLiveData.value?.item?.title?.let { + updateItemTitleInWebView(it) + } + } + } + + private fun updateItemTitleInWebView(title: String) { + val script = + "var event = new Event('updateTitle');event.title = '${title}';document.dispatchEvent(event);" CoroutineScope(Dispatchers.Main).launch { - enqueueScript(script) + enqueueScript(script) } - } } - } - fun updateItemTitle() { - viewModelScope.launch { - slug?.let { - loadItemFromDB(it) - } + fun createNewSavedItemLabel(labelName: String, hexColorValue: String) { + viewModelScope.launch { + withContext(Dispatchers.IO) { - webReaderParamsLiveData.value?.item?.title?.let { - updateItemTitleInWebView(it) - } - } - } + val newLabel = networker.createNewLabel( + CreateLabelInput( + color = presentIfNotNull(hexColorValue), + name = labelName + ) + ) - private fun updateItemTitleInWebView(title: String) { - val script = "var event = new Event('updateTitle');event.title = '${title}';document.dispatchEvent(event);" - CoroutineScope(Dispatchers.Main).launch { - enqueueScript(script) - } - } + newLabel?.let { + val savedItemLabel = SavedItemLabel( + savedItemLabelId = it.id, + name = it.name, + color = it.color, + createdAt = it.createdAt as String?, + labelDescription = it.description + ) - fun createNewSavedItemLabel(labelName: String, hexColorValue: String) { - viewModelScope.launch { - withContext(Dispatchers.IO) { - - val newLabel = networker.createNewLabel(CreateLabelInput(color = presentIfNotNull(hexColorValue), name = labelName)) - - newLabel?.let { - val savedItemLabel = SavedItemLabel( - savedItemLabelId = it.id, - name = it.name, - color = it.color, - createdAt = it.createdAt as String?, - labelDescription = it.description - ) - - dataService.db.savedItemLabelDao().insertAll(listOf(savedItemLabel)) + dataService.db.savedItemLabelDao().insertAll(listOf(savedItemLabel)) + } + } } - } } - } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/savedItemViews/SavedItemContextMenu.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/savedItemViews/SavedItemContextMenu.kt index ca55d69f9..eb7789609 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/savedItemViews/SavedItemContextMenu.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/savedItemViews/SavedItemContextMenu.kt @@ -2,6 +2,7 @@ package app.omnivore.omnivore.ui.savedItemViews import android.content.Context import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Share @@ -101,5 +102,18 @@ fun SavedItemContextMenu( ) } ) + DropdownMenuItem( + text = { Text(stringResource(R.string.saved_item_context_menu_action_mark_read)) }, + onClick = { + actionHandler(SavedItemAction.MarkRead) + onDismiss() + }, + leadingIcon = { + Icon( + Icons.Outlined.Check, + contentDescription = null + ) + } + ) } } diff --git a/android/Omnivore/app/src/main/res/values/strings.xml b/android/Omnivore/app/src/main/res/values/strings.xml index ad478efa8..b90e1a3dc 100644 --- a/android/Omnivore/app/src/main/res/values/strings.xml +++ b/android/Omnivore/app/src/main/res/values/strings.xml @@ -186,6 +186,7 @@ Unarchive Share Original Remove Item + Mark read Logout diff --git a/android/Omnivore/build.gradle b/android/Omnivore/build.gradle index 62770128d..325f20a00 100644 --- a/android/Omnivore/build.gradle +++ b/android/Omnivore/build.gradle @@ -3,7 +3,7 @@ buildscript { compose_version = '1.6.0' lifecycle_version = '2.5.1' hilt_version = '2.44.2' - gradle_plugin_version = '7.4.2' + gradle_plugin_version = '8.2.2' room_version = '2.4.3' kotlin_version = '1.7.10' } diff --git a/android/Omnivore/gradle.properties b/android/Omnivore/gradle.properties index b5987b651..a7cfaffbc 100644 --- a/android/Omnivore/gradle.properties +++ b/android/Omnivore/gradle.properties @@ -22,3 +22,5 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true +android.defaults.buildfeatures.buildconfig=true +android.nonFinalResIds=false diff --git a/android/Omnivore/gradle/wrapper/gradle-wrapper.properties b/android/Omnivore/gradle/wrapper/gradle-wrapper.properties index 5bf8d8081..58a908ba3 100644 --- a/android/Omnivore/gradle/wrapper/gradle-wrapper.properties +++ b/android/Omnivore/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Wed Aug 10 17:39:47 PDT 2022 +#Thu Feb 01 13:22:21 GMT 2024 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/Omnivore/settings.gradle b/android/Omnivore/settings.gradle.kts similarity index 87% rename from android/Omnivore/settings.gradle rename to android/Omnivore/settings.gradle.kts index d3111f0f7..038c37816 100644 --- a/android/Omnivore/settings.gradle +++ b/android/Omnivore/settings.gradle.kts @@ -13,8 +13,8 @@ dependencyResolutionManagement { maven { url = uri("https://customers.pspdfkit.com/maven") } - maven { url 'https://jitpack.io' } + maven(url = "https://jitpack.io") } } rootProject.name = "Omnivore" -include ':app' +include(":app")