Merge pull request #3523 from stefanosansone/feat/android-label-chips-m3-update

Android - Update label chips to M3
This commit is contained in:
Jackson Harper 2024-02-12 13:57:56 +08:00 committed by GitHub
commit 150444efb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
126 changed files with 3506 additions and 3319 deletions

View file

@ -3,10 +3,10 @@ plugins {
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
id 'com.apollographql.apollo3' version '3.7.2'
id("com.apollographql.apollo3") version "3.8.2"
}
def keystorePropertiesFile = rootProject.file("app/external/keystore.properties");
def keystorePropertiesFile = rootProject.file("app/external/keystore.properties")
def keystoreProperties = new Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
@ -19,8 +19,8 @@ android {
applicationId "app.omnivore.omnivore"
minSdk 26
targetSdk 34
versionCode 188
versionName "0.0.188"
versionCode 189
versionName "0.189.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -67,53 +67,60 @@ android {
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
jvmTarget = "1.8"
}
buildFeatures {
compose true
buildConfig true
}
composeOptions {
kotlinCompilerExtensionVersion = '1.3.1'
kotlinCompilerExtensionVersion = "1.5.8"
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
namespace 'app.omnivore.omnivore'
namespace "app.omnivore.omnivore"
}
dependencies {
def nav_version = "2.5.3"
implementation 'androidx.core:core-ktx:1.9.0'
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation "androidx.compose.material:material-icons-extended:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
implementation 'androidx.activity:activity-compose:1.8.2'
implementation 'androidx.appcompat:appcompat:1.5.1'
implementation 'com.google.android.gms:play-services-base:18.1.0'
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.gms:play-services-base:18.3.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation "androidx.navigation:navigation-compose:$nav_version"
// Compose
def composeBom = platform('androidx.compose:compose-bom:2024.01.00')
implementation(composeBom)
androidTestImplementation(composeBom)
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.4'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui:")
implementation("androidx.compose.material:material")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.activity:activity-compose:1.8.2")
implementation("androidx.navigation:navigation-compose:2.7.6")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
// Jetpack Lifecycle deps
// ViewModel
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")
implementation("androidx.compose.runtime:runtime-livedata:1.6.0")
// Saved state module for ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version")
@ -125,45 +132,44 @@ dependencies {
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
// coroutines
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
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.7.2'
implementation("com.apollographql.apollo3:apollo-runtime:3.8.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'
implementation "com.google.accompanist:accompanist-systemuicontroller:0.25.1"
implementation "com.google.accompanist:accompanist-flowlayout:0.25.1"
implementation 'io.coil-kt:coil-compose:2.3.0'
implementation("com.google.android.gms:play-services-auth:20.7.0")
implementation("com.google.accompanist:accompanist-systemuicontroller:0.34.0")
implementation("com.google.accompanist:accompanist-flowlayout:0.32.0")
implementation 'com.google.code.gson:gson:2.9.0'
implementation 'com.pspdfkit:pspdfkit:8.9.1'
implementation("io.coil-kt:coil-compose:2.5.0")
implementation 'com.posthog.android:posthog:2.0.3'
implementation 'io.intercom.android:intercom-sdk:15.1.0'
implementation("com.google.code.gson:gson:2.10")
implementation("com.pspdfkit:pspdfkit:8.9.1")
implementation("com.posthog.android:posthog:2.0.3")
implementation("io.intercom.android:intercom-sdk:15.1.0")
// Room Deps
implementation "androidx.room:room-runtime:$room_version"
implementation "androidx.room:room-ktx:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation("androidx.room:room-runtime:$room_version")
implementation("androidx.room:room-ktx:$room_version")
annotationProcessor("androidx.room:room-compiler:$room_version")
kapt("androidx.room:room-compiler:$room_version")
implementation 'com.github.jeziellago:compose-markdown:0.3.3'
implementation "io.github.dokar3:chiptextfield:0.4.7"
implementation("com.github.jeziellago:compose-markdown:0.3.3")
implementation("io.github.dokar3:chiptextfield-m3:0.6.5")
}
apollo {
packageName.set 'app.omnivore.omnivore.graphql.generated'
packageName.set "app.omnivore.omnivore.graphql.generated"
}
task printVersion {

View file

@ -31,7 +31,7 @@
</activity>
<activity
android:name=".ui.save.SaveSheetActivity"
android:name=".feature.save.SaveSheetActivity"
android:exported="true"
android:theme="@style/Theme.AppCompat.Translucent">
<intent-filter>
@ -47,12 +47,12 @@
android:windowSoftInputMode="adjustNothing" />
<activity
android:name=".ui.reader.PDFReaderActivity"
android:name=".feature.reader.PDFReaderActivity"
android:theme="@style/Theme.AppCompat.NoActionBar"
android:windowSoftInputMode="adjustNothing" />
<activity
android:name=".ui.reader.WebReaderLoadingContainerActivity"
android:name=".feature.reader.WebReaderLoadingContainerActivity"
android:exported="true"
android:theme="@style/Theme.Omnivore"/>

View file

@ -1,45 +0,0 @@
package app.omnivore.omnivore
import android.content.Context
import com.posthog.android.PostHog
import com.posthog.android.Properties
import io.intercom.android.sdk.Intercom
import io.intercom.android.sdk.identity.Registration
import org.json.JSONObject
import javax.inject.Inject
class EventTracker @Inject constructor(val app: Context) {
private val posthog: PostHog
init {
val posthogClientKey = app.getString(R.string.posthog_client_key)
val posthogInstanceAddress = app.getString(R.string.posthog_instance_address)
posthog = PostHog.Builder(app, posthogClientKey, posthogInstanceAddress)
.captureApplicationLifecycleEvents()
.collectDeviceId(false)
.build()
PostHog.setSingletonInstance(posthog)
}
fun registerUser(userID: String, intercomHash: String?, isDebug: Boolean) {
posthog.identify(userID)
if (!isDebug) {
Intercom.client().loginIdentifiedUser(Registration.create().withUserId(userID))
intercomHash?.let { intercomHash ->
Intercom.client().setUserHash(intercomHash)
}
}
}
fun track(eventName: String, properties: Properties = Properties()) {
posthog.capture(eventName, properties)
}
fun logout() {
posthog.reset()
}
}

View file

@ -12,15 +12,15 @@ import androidx.compose.ui.Modifier
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import app.omnivore.omnivore.ui.auth.LoginViewModel
import app.omnivore.omnivore.ui.components.LabelsViewModel
import app.omnivore.omnivore.ui.editinfo.EditInfoViewModel
import app.omnivore.omnivore.ui.library.LibraryViewModel
import app.omnivore.omnivore.ui.library.SearchViewModel
import app.omnivore.omnivore.ui.root.RootView
import app.omnivore.omnivore.ui.save.SaveViewModel
import app.omnivore.omnivore.ui.settings.SettingsViewModel
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
import app.omnivore.omnivore.feature.auth.LoginViewModel
import app.omnivore.omnivore.feature.components.LabelsViewModel
import app.omnivore.omnivore.feature.editinfo.EditInfoViewModel
import app.omnivore.omnivore.feature.library.LibraryViewModel
import app.omnivore.omnivore.feature.library.SearchViewModel
import app.omnivore.omnivore.feature.root.RootView
import app.omnivore.omnivore.feature.save.SaveViewModel
import app.omnivore.omnivore.feature.settings.SettingsViewModel
import app.omnivore.omnivore.feature.theme.OmnivoreTheme
import com.pspdfkit.PSPDFKit
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi

View file

@ -1,11 +0,0 @@
package app.omnivore.omnivore
sealed class Routes(val route: String) {
object Library : Routes("Library")
object Settings: Routes("Settings")
object Search: Routes("Search")
object Documentation: Routes("Documentation")
object PrivacyPolicy: Routes("PrivacyPolicy")
object TermsAndConditions: Routes("TermsAndConditions")
object Notebook: Routes("Notebook")
}

View file

@ -0,0 +1,45 @@
package app.omnivore.omnivore.core.analytics
import android.content.Context
import app.omnivore.omnivore.R
import com.posthog.android.PostHog
import com.posthog.android.Properties
import io.intercom.android.sdk.Intercom
import io.intercom.android.sdk.identity.Registration
import javax.inject.Inject
class EventTracker @Inject constructor(val app: Context) {
private val posthog: PostHog
init {
val posthogClientKey = app.getString(R.string.posthog_client_key)
val posthogInstanceAddress = app.getString(R.string.posthog_instance_address)
posthog = PostHog.Builder(app, posthogClientKey, posthogInstanceAddress)
.captureApplicationLifecycleEvents()
.collectDeviceId(false)
.build()
PostHog.setSingletonInstance(posthog)
}
fun registerUser(userID: String, intercomHash: String?, isDebug: Boolean) {
posthog.identify(userID)
if (!isDebug) {
Intercom.client().loginIdentifiedUser(Registration.create().withUserId(userID))
intercomHash?.let { intercomHash ->
Intercom.client().setUserHash(intercomHash)
}
}
}
fun track(eventName: String, properties: Properties = Properties()) {
posthog.capture(eventName, properties)
}
fun logout() {
posthog.reset()
}
}

View file

@ -1,11 +1,11 @@
package app.omnivore.omnivore.dataService
package app.omnivore.omnivore.core.data
import android.content.Context
import androidx.room.Room
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.AppDatabase
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.persistence.entities.SavedItem
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.database.AppDatabase
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItem
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import javax.inject.Inject

View file

@ -1,17 +1,16 @@
package app.omnivore.omnivore.dataService
package app.omnivore.omnivore.core.data
import android.util.Log
import app.omnivore.omnivore.graphql.generated.type.HighlightType
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.persistence.entities.SavedItemAndHighlightCrossRef
import app.omnivore.omnivore.persistence.entities.saveHighlightChange
import com.apollographql.apollo3.api.Optional
import app.omnivore.omnivore.core.network.CreateHighlightParams
import app.omnivore.omnivore.core.network.DeleteHighlightParams
import app.omnivore.omnivore.core.network.MergeHighlightsParams
import app.omnivore.omnivore.core.network.UpdateHighlightParams
import app.omnivore.omnivore.core.model.ServerSyncStatus
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItemAndHighlightCrossRef
import app.omnivore.omnivore.core.database.entities.saveHighlightChange
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.withContext
import java.util.*
@ -53,7 +52,7 @@ suspend fun DataService.createWebHighlight(jsonString: String, colorName: String
}
suspend fun DataService.createNoteHighlight(savedItemId: String, note: String): String {
val shortId = NanoId.generate(size=14)
val shortId = NanoId.generate(size = 14)
val createHighlightId = UUID.randomUUID().toString()
withContext(Dispatchers.IO) {

View file

@ -1,9 +1,14 @@
package app.omnivore.omnivore.dataService
package app.omnivore.omnivore.core.data
import android.util.Log
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.*
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.core.network.savedItem
import app.omnivore.omnivore.core.network.savedItemUpdates
import app.omnivore.omnivore.core.network.search
import app.omnivore.omnivore.core.model.ServerSyncStatus
suspend fun DataService.librarySearch(cursor: String?, query: String): SearchResult {
val searchResult = networker.search(cursor = cursor, limit = 10, query = query)

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.dataService
package app.omnivore.omnivore.core.data
import java.security.SecureRandom

View file

@ -0,0 +1,28 @@
package app.omnivore.omnivore.core.data
import app.omnivore.omnivore.core.model.ServerSyncStatus
import app.omnivore.omnivore.core.network.ReadingProgressParams
import app.omnivore.omnivore.core.network.updateReadingProgress
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun DataService.updateWebReadingProgress(jsonString: String) {
val readingProgressParams = Gson().fromJson(jsonString, ReadingProgressParams::class.java)
val savedItemId = readingProgressParams.id ?: return
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(savedItemId) ?: return@withContext
savedItem.readingProgress = readingProgressParams.readingProgressPercent ?: 0.0
savedItem.readingProgressAnchor = readingProgressParams.readingProgressAnchorIndex ?: 0
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.updateReadingProgress(readingProgressParams)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.savedItemDao().update(savedItem)
}
}
}

View file

@ -1,6 +1,6 @@
package app.omnivore.omnivore.dataService
package app.omnivore.omnivore.core.data
import app.omnivore.omnivore.networking.savedItemLabels
import app.omnivore.omnivore.core.network.savedItemLabels
suspend fun DataService.syncLabels() {
val fetchedLabels = networker.savedItemLabels()

View file

@ -0,0 +1,57 @@
package app.omnivore.omnivore.core.data
import app.omnivore.omnivore.core.model.ServerSyncStatus
import app.omnivore.omnivore.core.network.archiveSavedItem
import app.omnivore.omnivore.core.network.deleteSavedItem
import app.omnivore.omnivore.core.network.unarchiveSavedItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun DataService.deleteSavedItem(itemID: String) {
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(itemID = itemID) ?: return@withContext
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.deleteSavedItem(itemID)
if (isUpdatedOnServer) {
db.savedItemDao().deleteById(itemID)
}
}
}
suspend fun DataService.archiveSavedItem(itemID: String) {
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(itemID = itemID) ?: return@withContext
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
savedItem.isArchived = true
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.archiveSavedItem(itemID)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.savedItemDao().update(savedItem)
}
}
}
suspend fun DataService.unarchiveSavedItem(itemID: String) {
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(itemID = itemID) ?: return@withContext
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
savedItem.isArchived = false
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.unarchiveSavedItem(itemID)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.savedItemDao().update(savedItem)
}
}
}

View file

@ -1,21 +1,24 @@
package app.omnivore.omnivore.dataService
package app.omnivore.omnivore.core.data
import android.util.Log
import androidx.room.PrimaryKey
import app.omnivore.omnivore.core.network.ReadingProgressParams
import app.omnivore.omnivore.core.network.createHighlight
import app.omnivore.omnivore.core.network.deleteHighlights
import app.omnivore.omnivore.core.network.deleteSavedItem
import app.omnivore.omnivore.core.network.mergeHighlights
import app.omnivore.omnivore.core.network.updateArchiveStatusSavedItem
import app.omnivore.omnivore.core.network.updateHighlight
import app.omnivore.omnivore.core.network.updateReadingProgress
import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput
import app.omnivore.omnivore.graphql.generated.type.HighlightType
import app.omnivore.omnivore.graphql.generated.type.MergeHighlightInput
import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.persistence.entities.HighlightChange
import app.omnivore.omnivore.persistence.entities.SavedItem
import app.omnivore.omnivore.persistence.entities.highlightChangeToHighlight
import app.omnivore.omnivore.persistence.entities.saveHighlightChange
import app.omnivore.omnivore.core.model.ServerSyncStatus
import app.omnivore.omnivore.core.database.entities.HighlightChange
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.highlightChangeToHighlight
import com.apollographql.apollo3.api.Optional
import kotlinx.coroutines.delay
import kotlin.math.log
suspend fun DataService.startSyncChannels() {
Log.d("sync", "Starting sync channels")

View file

@ -0,0 +1,42 @@
package app.omnivore.omnivore.core.database
import androidx.room.Database
import androidx.room.RoomDatabase
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.HighlightChange
import app.omnivore.omnivore.core.database.entities.HighlightChangesDao
import app.omnivore.omnivore.core.database.entities.HighlightDao
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemAndHighlightCrossRef
import app.omnivore.omnivore.core.database.entities.SavedItemAndHighlightCrossRefDao
import app.omnivore.omnivore.core.database.entities.SavedItemAndSavedItemLabelCrossRef
import app.omnivore.omnivore.core.database.entities.SavedItemAndSavedItemLabelCrossRefDao
import app.omnivore.omnivore.core.database.entities.SavedItemDao
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemLabelDao
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlightsDao
import app.omnivore.omnivore.core.database.entities.Viewer
import app.omnivore.omnivore.core.database.entities.ViewerDao
@Database(
entities = [
Viewer::class,
SavedItem::class,
SavedItemLabel::class,
Highlight::class,
HighlightChange::class,
SavedItemAndSavedItemLabelCrossRef::class,
SavedItemAndHighlightCrossRef::class
],
version = 24
)
abstract class AppDatabase : RoomDatabase() {
abstract fun viewerDao(): ViewerDao
abstract fun savedItemDao(): SavedItemDao
abstract fun highlightDao(): HighlightDao
abstract fun highlightChangesDao(): HighlightChangesDao
abstract fun savedItemLabelDao(): SavedItemLabelDao
abstract fun savedItemWithLabelsAndHighlightsDao(): SavedItemWithLabelsAndHighlightsDao
abstract fun savedItemAndSavedItemLabelCrossRefDao(): SavedItemAndSavedItemLabelCrossRefDao
abstract fun savedItemAndHighlightCrossRefDao(): SavedItemAndHighlightCrossRefDao
}

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.persistence
package app.omnivore.omnivore.core.database
import androidx.room.Delete
import androidx.room.Insert

View file

@ -1,7 +1,7 @@
package app.omnivore.omnivore.persistence.entities
package app.omnivore.omnivore.core.database.entities
import androidx.room.*
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.core.model.ServerSyncStatus
import com.google.gson.annotations.SerializedName

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.persistence.entities
package app.omnivore.omnivore.core.database.entities
import android.util.Log
import androidx.room.Dao
@ -9,11 +9,9 @@ import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.core.model.ServerSyncStatus
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import com.google.gson.reflect.TypeToken
import kotlinx.serialization.json.Json
@Entity
@TypeConverters(StringListTypeConverter::class)

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.persistence.entities
package app.omnivore.omnivore.core.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.persistence.entities
package app.omnivore.omnivore.core.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.persistence.entities
package app.omnivore.omnivore.core.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.persistence.entities
package app.omnivore.omnivore.core.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey

View file

@ -0,0 +1,268 @@
package app.omnivore.omnivore.core.database.entities
import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.room.*
import java.util.*
@Entity
data class SavedItem(
@PrimaryKey val savedItemId: String,
val title: String,
val createdAt: String,
val savedAt: String,
val readAt: String?,
val updatedAt: String?,
var readingProgress: Double,
var readingProgressAnchor: Int,
val imageURLString: String?,
val pageURLString: String,
val descriptionText: String?,
val publisherURLString: String?,
val siteName: String?,
val author: String?,
val publishDate: String?,
val slug: String,
var isArchived: Boolean,
val contentReader: String? = null,
val content: String? = null,
val createdId: String? = null,
val htmlContent: String? = null,
val language: String? = null,
val listenPositionIndex: Int? = null,
val listenPositionOffset: Double? = null,
val listenPositionTime: Double? = null,
val localPDF: String? = null,
val onDeviceImageURLString: String? = null,
val originalHtml: String? = null,
@ColumnInfo(typeAffinity = ColumnInfo.BLOB) val pdfData: ByteArray? = null,
var serverSyncStatus: Int = 0,
val tempPDFURL: String? = null,
val wordsCount: Int? = null,
val localPDFPath: String? = null
// hasMany highlights
// hasMany labels
// has Many recommendations (rec has one savedItem)
) {
fun publisherDisplayName(): String? {
return publisherURLString?.toUri()?.host
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SavedItem
return savedItemId == other.savedItemId
}
override fun hashCode(): Int {
return savedItemId.hashCode()
}
}
data class TypeaheadCardData(
val savedItemId: String,
val slug: String,
val title: String,
val isArchived: Boolean,
)
@Dao
abstract class SavedItemWithLabelsAndHighlightsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertSavedItems(items: List<SavedItem>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertLabelCrossRefs(items: List<SavedItemAndSavedItemLabelCrossRef>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertLabels(items: List<SavedItemLabel>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertHighlights(items: List<Highlight>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertHighlightCrossRefs(items: List<SavedItemAndHighlightCrossRef>)
@Transaction
open fun insertAll(savedItems: List<SavedItemWithLabelsAndHighlights>) {
insertSavedItems(savedItems.map { it.savedItem })
val labels: MutableList<SavedItemLabel> = mutableListOf()
val highlights: MutableList<Highlight> = mutableListOf()
val labelCrossRefs: MutableList<SavedItemAndSavedItemLabelCrossRef> = mutableListOf()
val highlightCrossRefs: MutableList<SavedItemAndHighlightCrossRef> = mutableListOf()
for (searchItem in savedItems) {
labels.addAll(searchItem.labels)
highlights.addAll(searchItem.highlights)
val newLabelCrossRefs = searchItem.labels.map {
SavedItemAndSavedItemLabelCrossRef(
savedItemLabelId = it.savedItemLabelId,
savedItemId = searchItem.savedItem.savedItemId
)
}
val newHighlightCrossRefs = searchItem.highlights.map {
SavedItemAndHighlightCrossRef(
highlightId = it.highlightId,
savedItemId = searchItem.savedItem.savedItemId
)
}
labelCrossRefs.addAll(newLabelCrossRefs)
highlightCrossRefs.addAll(newHighlightCrossRefs)
}
insertLabels(labels)
insertLabelCrossRefs(labelCrossRefs)
insertHighlights(highlights)
insertHighlightCrossRefs(highlightCrossRefs)
}
}
@Dao
interface SavedItemDao {
@Query("SELECT * FROM savedItem")
fun getAll(): List<SavedItem>
@Query("SELECT * FROM savedItem WHERE savedItemId = :itemID")
fun findById(itemID: String): SavedItem?
@Query("SELECT * FROM savedItem WHERE serverSyncStatus != 0")
fun getUnSynced(): List<SavedItem>
@Query("SELECT * FROM savedItem WHERE slug = :slug")
fun getSavedItemWithLabelsAndHighlights(slug: String): SavedItemWithLabelsAndHighlights?
@Query("DELETE FROM savedItem WHERE savedItemId = :itemID")
fun deleteById(itemID: String)
@Query("DELETE FROM savedItem WHERE savedItemId in (:itemIDs)")
fun deleteByIds(itemIDs: List<String>)
@Update
fun update(savedItem: SavedItem)
@Transaction
@Query(
"SELECT ${SavedItemQueryConstants.libraryColumns} " +
"FROM SavedItem " +
"LEFT OUTER JOIN SavedItemAndSavedItemLabelCrossRef on SavedItem.savedItemId = SavedItemAndSavedItemLabelCrossRef.savedItemId " +
"LEFT OUTER JOIN SavedItemAndHighlightCrossRef on SavedItem.savedItemId = SavedItemAndHighlightCrossRef.savedItemId " +
"LEFT OUTER JOIN SavedItemLabel on SavedItemLabel.savedItemLabelId = SavedItemAndSavedItemLabelCrossRef.savedItemLabelId " +
"LEFT OUTER JOIN Highlight on highlight.highlightId = SavedItemAndHighlightCrossRef.highlightId " +
"WHERE SavedItem.savedItemId = :savedItemId " +
"GROUP BY SavedItem.savedItemId "
)
fun getLibraryItemById(savedItemId: String): LiveData<SavedItemWithLabelsAndHighlights>
@Transaction
@Query(
"SELECT ${SavedItemQueryConstants.libraryColumns} " +
"FROM SavedItem " +
"LEFT OUTER JOIN SavedItemAndSavedItemLabelCrossRef on SavedItem.savedItemId = SavedItemAndSavedItemLabelCrossRef.savedItemId " +
"LEFT OUTER JOIN SavedItemAndHighlightCrossRef on SavedItem.savedItemId = SavedItemAndHighlightCrossRef.savedItemId " +
"LEFT OUTER JOIN SavedItemLabel on SavedItemLabel.savedItemLabelId = SavedItemAndSavedItemLabelCrossRef.savedItemLabelId " +
"LEFT OUTER JOIN Highlight on highlight.highlightId = SavedItemAndHighlightCrossRef.highlightId " +
"WHERE SavedItem.savedItemId = :savedItemId " +
"GROUP BY SavedItem.savedItemId "
)
suspend fun getById(savedItemId: String): SavedItemWithLabelsAndHighlights?
@Transaction
@Query(
"SELECT ${SavedItemQueryConstants.libraryColumns} " +
"FROM SavedItem " +
"LEFT OUTER JOIN SavedItemAndSavedItemLabelCrossRef on SavedItem.savedItemId = SavedItemAndSavedItemLabelCrossRef.savedItemId " +
"LEFT OUTER JOIN SavedItemAndHighlightCrossRef on SavedItem.savedItemId = SavedItemAndHighlightCrossRef.savedItemId " +
"LEFT OUTER JOIN SavedItemLabel on SavedItemLabel.savedItemLabelId = SavedItemAndSavedItemLabelCrossRef.savedItemLabelId " +
"LEFT OUTER JOIN Highlight on highlight.highlightId = SavedItemAndHighlightCrossRef.highlightId " +
"WHERE SavedItem.serverSyncStatus != 2 " +
"AND SavedItem.isArchived IN (:allowedArchiveStates) " +
"AND SavedItem.contentReader IN (:allowedContentReaders) " +
"AND CASE WHEN :hasRequiredLabels THEN SavedItemLabel.name in (:requiredLabels) ELSE 1 END " +
"AND CASE WHEN :hasExcludedLabels THEN SavedItemLabel.name is NULL OR SavedItemLabel.name not in (:excludedLabels) ELSE 1 END " +
"GROUP BY SavedItem.savedItemId " +
"ORDER BY \n" +
"CASE WHEN :sortKey = 'newest' THEN SavedItem.savedAt END DESC,\n" +
"CASE WHEN :sortKey = 'oldest' THEN SavedItem.savedAt END ASC,\n" +
"CASE WHEN :sortKey = 'recentlyRead' THEN SavedItem.readAt END DESC,\n" +
"CASE WHEN :sortKey = 'recentlyPublished' THEN SavedItem.publishDate END DESC"
)
fun _filteredLibraryData(
allowedArchiveStates: List<Int>,
sortKey: String,
hasRequiredLabels: Int,
hasExcludedLabels: Int,
requiredLabels: List<String>,
excludedLabels: List<String>,
allowedContentReaders: List<String>
): LiveData<List<SavedItemWithLabelsAndHighlights>>
fun filteredLibraryData(
allowedArchiveStates: List<Int>,
sortKey: String,
requiredLabels: List<String>,
excludedLabels: List<String>,
allowedContentReaders: List<String>
): LiveData<List<SavedItemWithLabelsAndHighlights>> {
return _filteredLibraryData(
allowedArchiveStates = allowedArchiveStates,
sortKey = sortKey,
hasRequiredLabels = requiredLabels.size,
hasExcludedLabels = excludedLabels.size,
requiredLabels = requiredLabels,
excludedLabels = excludedLabels,
allowedContentReaders = allowedContentReaders
)
}
}
object SavedItemQueryConstants {
const val columns =
"savedItemId, slug, publisherURLString, title, author, descriptionText, imageURLString, isArchived, pageURLString, contentReader, savedAt, readingProgress, wordsCount"
const val libraryColumns = "SavedItem.savedItemId, " +
"SavedItem.slug, " +
"SavedItem.createdAt, " +
"SavedItem.publisherURLString, " +
"SavedItem.title, " +
"SavedItem.author, " +
"SavedItem.descriptionText, " +
"SavedItem.imageURLString, " +
"SavedItem.isArchived, " +
"SavedItem.pageURLString, " +
"SavedItem.contentReader, " +
"SavedItem.savedAt, " +
"SavedItem.readingProgress, " +
"SavedItem.readingProgressAnchor, " +
"SavedItem.serverSyncStatus, " +
"SavedItem.wordsCount, " +
"SavedItemLabel.savedItemLabelId, " +
"SavedItemLabel.name, " +
"SavedItemLabel.color, " +
"Highlight.highlightId, " +
"Highlight.shortId, " +
"Highlight.createdByMe "
}

View file

@ -1,8 +1,8 @@
package app.omnivore.omnivore.persistence.entities
package app.omnivore.omnivore.core.database.entities
import androidx.lifecycle.LiveData
import androidx.room.*
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.core.model.ServerSyncStatus
@Entity
data class SavedItemLabel(

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.persistence.entities
package app.omnivore.omnivore.core.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey

View file

@ -1,7 +1,6 @@
package app.omnivore.omnivore.persistence.entities
package app.omnivore.omnivore.core.database.entities
import androidx.room.*
import app.omnivore.omnivore.persistence.BaseDao
@Entity
data class Viewer(

View file

@ -1,9 +1,11 @@
package app.omnivore.omnivore
package app.omnivore.omnivore.core.datastore
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import app.omnivore.omnivore.utils.Constants
import app.omnivore.omnivore.utils.DatastoreKeys
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map

View file

@ -0,0 +1,10 @@
package app.omnivore.omnivore.core.model
enum class ServerSyncStatus(val rawValue: Int) {
IS_SYNCED(0),
IS_SYNCING(1),
NEEDS_DELETION(2),
NEEDS_CREATION(3),
NEEDS_UPDATE(4),
NEEDS_MERGE(5)
}

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import android.util.Log
import app.omnivore.omnivore.graphql.generated.CreateHighlightMutation
@ -10,7 +10,7 @@ import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput
import app.omnivore.omnivore.graphql.generated.type.HighlightType
import app.omnivore.omnivore.graphql.generated.type.MergeHighlightInput
import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.core.database.entities.Highlight
import com.apollographql.apollo3.api.Optional
import com.google.gson.Gson

View file

@ -1,8 +1,8 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.Constants
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.DatastoreRepository
import app.omnivore.omnivore.utils.Constants
import app.omnivore.omnivore.utils.DatastoreKeys
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import com.apollographql.apollo3.ApolloClient
import javax.inject.Inject

View file

@ -1,6 +1,5 @@
package app.omnivore.omnivore
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.networking.Networker
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.graphql.generated.SaveArticleReadingProgressMutation
import app.omnivore.omnivore.graphql.generated.type.SaveArticleReadingProgressInput

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.graphql.generated.CreateLabelMutation
import app.omnivore.omnivore.graphql.generated.SetLabelsMutation

View file

@ -1,7 +1,7 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.graphql.generated.GetLabelsQuery
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
suspend fun Networker.savedItemLabels(): List<SavedItemLabel> {

View file

@ -0,0 +1,65 @@
package app.omnivore.omnivore.core.network
import android.net.Uri
import androidx.compose.ui.text.intl.Locale
import app.omnivore.omnivore.graphql.generated.SaveUrlMutation
import app.omnivore.omnivore.graphql.generated.SetBookmarkArticleMutation
import app.omnivore.omnivore.graphql.generated.SetLinkArchivedMutation
import app.omnivore.omnivore.graphql.generated.type.ArchiveLinkInput
import app.omnivore.omnivore.graphql.generated.type.SaveUrlInput
import app.omnivore.omnivore.graphql.generated.type.SetBookmarkArticleInput
import com.apollographql.apollo3.api.Optional
import java.util.TimeZone
import java.util.UUID
suspend fun Networker.deleteSavedItem(itemID: String): Boolean {
return try {
val input = SetBookmarkArticleInput(itemID, false)
val result =
authenticatedApolloClient().mutation(SetBookmarkArticleMutation(input)).execute()
result.data?.setBookmarkArticle?.onSetBookmarkArticleSuccess?.bookmarkedArticle?.id != null
} catch (e: java.lang.Exception) {
false
}
}
suspend fun Networker.archiveSavedItem(itemID: String): Boolean {
return updateArchiveStatusSavedItem(itemID, true)
}
suspend fun Networker.unarchiveSavedItem(itemID: String): Boolean {
return updateArchiveStatusSavedItem(itemID, false)
}
suspend fun Networker.updateArchiveStatusSavedItem(
itemID: String,
setAsArchived: Boolean
): Boolean {
return try {
val input = ArchiveLinkInput(setAsArchived, itemID)
val result = authenticatedApolloClient().mutation(SetLinkArchivedMutation(input)).execute()
result.data?.setLinkArchived?.onArchiveLinkSuccess?.linkId != null
} catch (e: java.lang.Exception) {
false
}
}
suspend fun Networker.saveUrl(url: Uri): Boolean {
return try {
val clientRequestId = UUID.randomUUID().toString()
// get locale and timezone from device
val timezone = TimeZone.getDefault().id
val locale = Locale.current.toLanguageTag()
val input = SaveUrlInput(
url = url.toString(),
clientRequestId = clientRequestId,
source = "android",
timezone = Optional.present(timezone),
locale = Optional.present(locale)
)
val result = authenticatedApolloClient().mutation(SaveUrlMutation(input)).execute()
result.data?.saveUrl?.onSaveSuccess?.url != null
} catch (e: java.lang.Exception) {
false
}
}

View file

@ -1,11 +1,11 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import android.util.Log
import app.omnivore.omnivore.graphql.generated.GetArticleQuery
import app.omnivore.omnivore.graphql.generated.type.ContentReader
import app.omnivore.omnivore.persistence.entities.SavedItem
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.Highlight
import java.io.File
import java.net.URL
import java.nio.file.Files

View file

@ -1,8 +1,7 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.graphql.generated.UpdatesSinceQuery
import app.omnivore.omnivore.graphql.generated.type.UpdateReason
import app.omnivore.omnivore.persistence.entities.SavedItem
import com.apollographql.apollo3.api.Optional
data class SavedItemUpdatesQueryResponse(

View file

@ -1,8 +1,10 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.graphql.generated.SearchQuery
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.persistence.entities.*
import app.omnivore.omnivore.core.model.ServerSyncStatus
import com.apollographql.apollo3.api.Optional
data class LibrarySearchQueryResponse(

View file

@ -1,7 +1,7 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.graphql.generated.TypeaheadSearchQuery
import app.omnivore.omnivore.persistence.entities.TypeaheadCardData
import app.omnivore.omnivore.core.database.entities.TypeaheadCardData
data class SearchQueryResponse(
val cursor: String?,

View file

@ -1,7 +1,7 @@
package app.omnivore.omnivore.networking
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.graphql.generated.ViewerQuery
import app.omnivore.omnivore.persistence.entities.Viewer
import app.omnivore.omnivore.core.database.entities.Viewer
suspend fun Networker.viewer(): Viewer? {
try {

View file

@ -1,28 +0,0 @@
package app.omnivore.omnivore.dataService
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.ReadingProgressParams
import app.omnivore.omnivore.networking.updateReadingProgress
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun DataService.updateWebReadingProgress(jsonString: String) {
val readingProgressParams = Gson().fromJson(jsonString, ReadingProgressParams::class.java)
val savedItemId = readingProgressParams.id ?: return
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(savedItemId) ?: return@withContext
savedItem.readingProgress = readingProgressParams.readingProgressPercent ?: 0.0
savedItem.readingProgressAnchor = readingProgressParams.readingProgressAnchorIndex ?: 0
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.updateReadingProgress(readingProgressParams)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.savedItemDao().update(savedItem)
}
}
}

View file

@ -1,56 +0,0 @@
package app.omnivore.omnivore.dataService
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.archiveSavedItem
import app.omnivore.omnivore.networking.deleteSavedItem
import app.omnivore.omnivore.networking.unarchiveSavedItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun DataService.deleteSavedItem(itemID: String) {
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(itemID = itemID) ?: return@withContext
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.deleteSavedItem(itemID)
if (isUpdatedOnServer) {
db.savedItemDao().deleteById(itemID)
}
}
}
suspend fun DataService.archiveSavedItem(itemID: String) {
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(itemID = itemID) ?: return@withContext
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
savedItem.isArchived = true
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.archiveSavedItem(itemID)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.savedItemDao().update(savedItem)
}
}
}
suspend fun DataService.unarchiveSavedItem(itemID: String) {
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(itemID = itemID) ?: return@withContext
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
savedItem.isArchived = false
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.unarchiveSavedItem(itemID)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.savedItemDao().update(savedItem)
}
}
}

View file

@ -1,8 +1,11 @@
package app.omnivore.omnivore
package app.omnivore.omnivore.di
import android.content.Context
import app.omnivore.omnivore.dataService.DataService
import app.omnivore.omnivore.networking.Networker
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.core.analytics.EventTracker
import app.omnivore.omnivore.core.datastore.OmnivoreDatastore
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.core.network.Networker
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn

View file

@ -1,12 +1,12 @@
package app.omnivore.omnivore.ui
package app.omnivore.omnivore.feature
import app.omnivore.omnivore.dataService.DataService
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput
import app.omnivore.omnivore.networking.Networker
import app.omnivore.omnivore.networking.updateLabelsForSavedItem
import app.omnivore.omnivore.persistence.entities.SavedItemAndSavedItemLabelCrossRef
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.updateLabelsForSavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemAndSavedItemLabelCrossRef
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import com.apollographql.apollo3.api.Optional

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui
package app.omnivore.omnivore.feature
import android.content.Context
import androidx.annotation.StringRes

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import android.annotation.SuppressLint
import android.net.Uri
@ -17,7 +17,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import app.omnivore.omnivore.AppleConstants
import app.omnivore.omnivore.utils.AppleConstants
import app.omnivore.omnivore.R
import java.net.URLEncoder
import java.util.*

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import android.annotation.SuppressLint
import android.widget.Toast

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import android.annotation.SuppressLint
import android.widget.Toast
@ -27,7 +27,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.BuildConfig
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.auth.AuthUtils.autofill
import app.omnivore.omnivore.feature.auth.AuthUtils.autofill
@SuppressLint("CoroutineCreationDuringComposition")
@Composable

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import android.annotation.SuppressLint
import android.widget.Toast
@ -29,7 +29,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.auth.AuthUtils.autofill
import app.omnivore.omnivore.feature.auth.AuthUtils.autofill
@Composable
fun EmailSignUpView(viewModel: LoginViewModel) {

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import android.content.Context
import android.widget.Toast
@ -7,11 +7,26 @@ 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.core.analytics.EventTracker
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.core.network.AuthProviderLoginSubmit
import app.omnivore.omnivore.core.network.CreateAccountParams
import app.omnivore.omnivore.core.network.CreateAccountSubmit
import app.omnivore.omnivore.core.network.CreateEmailAccountSubmit
import app.omnivore.omnivore.core.network.EmailLoginCredentials
import app.omnivore.omnivore.core.network.EmailLoginSubmit
import app.omnivore.omnivore.core.network.EmailSignUpParams
import app.omnivore.omnivore.graphql.generated.ValidateUsernameQuery
import app.omnivore.omnivore.networking.Networker
import app.omnivore.omnivore.networking.viewer
import app.omnivore.omnivore.ui.ResourceProvider
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.PendingUserSubmit
import app.omnivore.omnivore.core.network.RetrofitHelper
import app.omnivore.omnivore.core.network.SignInParams
import app.omnivore.omnivore.core.network.UserProfile
import app.omnivore.omnivore.core.network.viewer
import app.omnivore.omnivore.feature.ResourceProvider
import app.omnivore.omnivore.utils.Constants
import app.omnivore.omnivore.utils.DatastoreKeys
import com.apollographql.apollo3.ApolloClient
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.common.api.ApiException

View file

@ -1,13 +1,8 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.ClickableText
@ -19,22 +14,16 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import app.omnivore.omnivore.BuildConfig
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.R
@SuppressLint("CoroutineCreationDuringComposition")

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.auth
package app.omnivore.omnivore.feature.auth
import android.annotation.SuppressLint
import android.content.Intent
@ -21,7 +21,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
import app.omnivore.omnivore.feature.theme.OmnivoreTheme
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.android.gms.common.GoogleApiAvailability
import kotlinx.coroutines.launch

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import android.widget.Toast
import androidx.compose.foundation.*
@ -18,14 +18,13 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.MutableLiveData
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.save.SaveState
import app.omnivore.omnivore.ui.save.SaveViewModel
import app.omnivore.omnivore.feature.save.SaveState
import app.omnivore.omnivore.feature.save.SaveViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import androidx.compose.ui.graphics.Color

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import androidx.compose.ui.graphics.Color
enum class HighlightColorPaletteMode(val backgroundColor: Color) {

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background

View file

@ -1,6 +1,4 @@
@file:OptIn(ExperimentalMaterialApi::class)
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import android.widget.Toast
import androidx.compose.foundation.Canvas
@ -8,7 +6,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@ -20,22 +17,15 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Scaffold
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircle
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@ -45,7 +35,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
@ -58,86 +47,21 @@ import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.toLowerCase
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.omnivore.omnivore.R
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import com.dokar.chiptextfield.Chip
import com.dokar.chiptextfield.ChipTextField
import com.dokar.chiptextfield.ChipTextFieldDefaults
import com.dokar.chiptextfield.ChipTextFieldState
import com.dokar.chiptextfield.m3.ChipTextField
import com.dokar.chiptextfield.m3.ChipTextFieldDefaults
import com.dokar.chiptextfield.rememberChipTextFieldState
import com.google.accompanist.flowlayout.FlowRow
//@Composable
//fun LabelsSelectionSheet(viewModel: LibraryViewModel) {
// val isActive: Boolean by viewModel.showLabelsSelectionSheetLiveData.observeAsState(false)
// val labels: List<SavedItemLabel> by viewModel.savedItemLabelsLiveData.observeAsState(listOf())
// val currentSavedItemData = viewModel.currentSavedItemUnderEdit()
//
// val modalBottomSheetState = rememberModalBottomSheetState(
// ModalBottomSheetValue.HalfExpanded,
// confirmStateChange = { it != ModalBottomSheetValue.Hidden }
// )
//
// if (isActive) {
// ModalBottomSheetLayout(
// sheetBackgroundColor = Color.Transparent,
// sheetState = modalBottomSheetState,
// sheetContent = {
// BottomSheetUI {
// if (currentSavedItemData != null) {
// LabelsSelectionSheetContent(
// labels = labels,
// initialSelectedLabels = currentSavedItemData.labels,
// onCancel = {
// viewModel.showLabelsSelectionSheetLiveData.value = false
// viewModel.labelsSelectionCurrentItemLiveData.value = null
// },
// isLibraryMode = false,
// onSave = {
// if (it != labels) {
// viewModel.updateSavedItemLabels(
// savedItemID = currentSavedItemData.savedItem.savedItemId,
// labels = it
// )
// }
// viewModel.labelsSelectionCurrentItemLiveData.value = null
// viewModel.showLabelsSelectionSheetLiveData.value = false
// },
// onCreateLabel = { newLabelName, labelHexValue ->
// viewModel.createNewSavedItemLabel(newLabelName, labelHexValue)
// }
// )
// } else { // Is used in library mode
// LabelsSelectionSheetContent(
// labels = labels,
// initialSelectedLabels = viewModel.activeLabelsLiveData.value ?: listOf(),
// onCancel = { viewModel.showLabelsSelectionSheetLiveData.value = false },
// isLibraryMode = true,
// onSave = {
// viewModel.updateAppliedLabels(it)
// viewModel.labelsSelectionCurrentItemLiveData.value = null
// viewModel.showLabelsSelectionSheetLiveData.value = false
// },
// onCreateLabel = { newLabelName, labelHexValue ->
// viewModel.createNewSavedItemLabel(newLabelName, labelHexValue)
// }
// )
// }
// }
// }
// ) {}
// }
//}
@Composable
fun CircleIcon(colorHex: String) {
val chipColors = LabelChipColors.fromHex(colorHex)
@ -250,10 +174,7 @@ fun findOrCreateLabel(
}
@Composable
@OptIn(
ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class,
ExperimentalMaterial3Api::class
)
@OptIn(ExperimentalMaterial3Api::class)
fun LabelsSelectionSheetContent(
isLibraryMode: Boolean,
labels: List<SavedItemLabel>,
@ -355,10 +276,10 @@ fun LabelsSelectionSheetContent(
focusedBorderColor = Color(0xFF2A2A2A),
focusedBackgroundColor = Color(0xFF2A2A2A)
),
colors = TextFieldDefaults.textFieldColors(
/* colors = androidx.compose.material3.TextFieldDefaults.colors(
textColor = MaterialTheme.colorScheme.onBackground,
backgroundColor = MaterialTheme.colorScheme.surface
),
),*/
contentPadding = PaddingValues(10.dp),
modifier = Modifier
.defaultMinSize(minHeight = 45.dp)

View file

@ -1,8 +1,8 @@
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import androidx.lifecycle.*
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.core.model.ServerSyncStatus
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import dagger.hilt.android.lifecycle.HiltViewModel
import java.time.LocalDate
import java.time.ZoneOffset

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.components
package app.omnivore.omnivore.feature.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Row

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.editinfo
package app.omnivore.omnivore.feature.editinfo
import android.widget.Toast
import androidx.compose.foundation.*
@ -11,7 +11,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.editinfo
package app.omnivore.omnivore.feature.editinfo
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -6,14 +6,14 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.Constants
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.DatastoreRepository
import app.omnivore.omnivore.utils.Constants
import app.omnivore.omnivore.utils.DatastoreKeys
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.R
import app.omnivore.omnivore.dataService.DataService
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.graphql.generated.UpdatePageMutation
import app.omnivore.omnivore.graphql.generated.type.UpdatePageInput
import app.omnivore.omnivore.ui.ResourceProvider
import app.omnivore.omnivore.feature.ResourceProvider
import com.apollographql.apollo3.ApolloClient
import com.apollographql.apollo3.api.Optional
import dagger.hilt.android.lifecycle.HiltViewModel

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.library
package app.omnivore.omnivore.feature.library
enum class LibraryBottomSheetState {
HIDDEN,

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.library
package app.omnivore.omnivore.feature.library
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
@ -17,8 +17,8 @@ import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.toLowerCase
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.R
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.ui.components.LabelChipColors
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.feature.components.LabelChipColors
@Composable
fun LibraryFilterBar(viewModel: LibraryViewModel) {

View file

@ -0,0 +1,234 @@
package app.omnivore.omnivore.feature.library
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.navigation.NavHostController
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryNavigationBar(
savedItemViewModel: SavedItemViewModel,
onSearchClicked: () -> Unit,
onAddLinkClicked: () -> Unit,
onSettingsIconClick: () -> Unit
) {
val actionsMenuItem: SavedItemWithLabelsAndHighlights? by savedItemViewModel.actionsMenuItemLiveData.observeAsState(
null
)
var isMenuExpanded by remember { mutableStateOf(false) }
TopAppBar(
title = {
Text(
if (actionsMenuItem == null)
stringResource(R.string.library_nav_bar_title) else
stringResource(R.string.library_nav_bar_title_alt)
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = if (actionsMenuItem == null) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.surfaceVariant
),
navigationIcon = {
if (actionsMenuItem != null) {
IconButton(onClick = {
savedItemViewModel.actionsMenuItemLiveData.postValue(null)
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
modifier = Modifier,
contentDescription = "Back"
)
}
}
},
actions = {
actionsMenuItem?.let {
IconButton(onClick = {
savedItemViewModel.handleSavedItemAction(
it.savedItem.savedItemId,
if (it.savedItem.isArchived) SavedItemAction.Unarchive else SavedItemAction.Archive
)
}) {
if (it.savedItem.isArchived) {
Icon(
painter = painterResource(id = R.drawable.unarchive),
contentDescription = null
)
} else {
Icon(
painter = painterResource(id = R.drawable.archive_outline),
contentDescription = null
)
}
}
IconButton(onClick = {
savedItemViewModel.handleSavedItemAction(
it.savedItem.savedItemId,
SavedItemAction.EditInfo
)
}) {
Icon(
Icons.Outlined.Info,
contentDescription = null
)
}
IconButton(onClick = {
savedItemViewModel.handleSavedItemAction(
it.savedItem.savedItemId,
SavedItemAction.EditLabels
)
}) {
Icon(
painter = painterResource(id = R.drawable.tag),
contentDescription = null
)
}
IconButton(onClick = {
savedItemViewModel.handleSavedItemAction(
it.savedItem.savedItemId,
SavedItemAction.Delete
)
}) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = null
)
}
/* IconButton(onClick = { isMenuExpanded = true } ) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = null
)
if (isMenuExpanded) {
SavedItemLibraryContextMenu(
savedItemViewModel = savedItemViewModel,
savedItem = it.savedItem,
isExpanded = true,
onDismiss = { isMenuExpanded = false },
)
}
}*/
} ?: run {
IconButton(onClick = onSearchClicked) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null
)
}
IconButton(onClick = onAddLinkClicked) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = null
)
}
IconButton(onClick = onSettingsIconClick) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = null
)
}
}
}
)
}
@Composable
fun SearchField(
searchText: String,
onSearch: () -> Unit,
onSearchTextChanged: (String) -> Unit,
navController: NavHostController,
) {
var showClearButton by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
TextField(
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focusState ->
showClearButton = (focusState.isFocused)
}
.focusRequester(focusRequester),
value = searchText,
onValueChange = onSearchTextChanged,
placeholder = {
Text(text = stringResource(R.string.library_nav_bar_field_placeholder_search))
},
leadingIcon = {
IconButton(
onClick = {
onSearchTextChanged("")
navController.popBackStack()
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
trailingIcon = {
AnimatedVisibility(
visible = showClearButton,
enter = fadeIn(),
exit = fadeOut()
) {
IconButton(onClick = { onSearchTextChanged("") }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = null
)
}
}
},
maxLines = 1,
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = {
onSearch()
}),
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.library
package app.omnivore.omnivore.feature.library
import android.content.Intent
import android.util.Log
@ -53,19 +53,19 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import app.omnivore.omnivore.Routes
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.ui.components.AddLinkSheetContent
import app.omnivore.omnivore.ui.components.LabelsSelectionSheetContent
import app.omnivore.omnivore.ui.components.LabelsViewModel
import app.omnivore.omnivore.ui.editinfo.EditInfoSheetContent
import app.omnivore.omnivore.ui.editinfo.EditInfoViewModel
import app.omnivore.omnivore.ui.reader.PDFReaderActivity
import app.omnivore.omnivore.ui.reader.WebReaderLoadingContainerActivity
import app.omnivore.omnivore.ui.save.SaveState
import app.omnivore.omnivore.ui.save.SaveViewModel
import app.omnivore.omnivore.ui.savedItemViews.SavedItemCard
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.feature.components.AddLinkSheetContent
import app.omnivore.omnivore.feature.components.LabelsSelectionSheetContent
import app.omnivore.omnivore.feature.components.LabelsViewModel
import app.omnivore.omnivore.feature.editinfo.EditInfoSheetContent
import app.omnivore.omnivore.feature.editinfo.EditInfoViewModel
import app.omnivore.omnivore.feature.reader.PDFReaderActivity
import app.omnivore.omnivore.feature.reader.WebReaderLoadingContainerActivity
import app.omnivore.omnivore.feature.save.SaveState
import app.omnivore.omnivore.feature.save.SaveViewModel
import app.omnivore.omnivore.feature.savedItemViews.SavedItemCard
import app.omnivore.omnivore.navigation.Routes
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch

View file

@ -1,20 +1,43 @@
package app.omnivore.omnivore.ui.library
package app.omnivore.omnivore.feature.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.utils.DatastoreKeys
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.core.data.archiveSavedItem
import app.omnivore.omnivore.core.data.deleteSavedItem
import app.omnivore.omnivore.core.data.fetchSavedItemContent
import app.omnivore.omnivore.core.data.isSavedItemContentStoredInDB
import app.omnivore.omnivore.core.data.librarySearch
import app.omnivore.omnivore.core.data.sync
import app.omnivore.omnivore.core.data.syncLabels
import app.omnivore.omnivore.core.data.syncOfflineItemsWithServerIfNeeded
import app.omnivore.omnivore.core.data.unarchiveSavedItem
import app.omnivore.omnivore.core.data.updateWebReadingProgress
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.*
import app.omnivore.omnivore.ui.ResourceProvider
import app.omnivore.omnivore.ui.setSavedItemLabels
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.createNewLabel
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.feature.ResourceProvider
import app.omnivore.omnivore.feature.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
@ -25,8 +48,8 @@ class LibraryViewModel @Inject constructor(
private val datastoreRepo: DatastoreRepository,
private val resourceProvider: ResourceProvider
) : ViewModel(), SavedItemViewModel {
private val contentRequestChannel = Channel<String>(capacity = Channel.UNLIMITED)
private val contentRequestChannel = Channel<String>(capacity = Channel.UNLIMITED)
private var cursor: String? = null
private var librarySearchCursor: String? = null
@ -175,12 +198,6 @@ class LibraryViewModel @Inject constructor(
}
}
// fun sortKey(appliedSortKey: String) {
// when(appliedSortKey) {
//
// }
// }
private fun handleFilterChanges() {
librarySearchCursor = null
@ -307,6 +324,34 @@ 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
)
)
)
}
}
SavedItemAction.MarkUnread -> {
viewModelScope.launch {
dataService.updateWebReadingProgress(
jsonString = Gson().toJson(
mapOf(
"id" to itemID,
"readingProgressPercent" to 0,
"readingProgressAnchorIndex" to 0
)
)
)
}
}
}
actionsMenuItemLiveData.postValue(null)
}
@ -405,4 +450,6 @@ enum class SavedItemAction {
Unarchive,
EditLabels,
EditInfo,
MarkRead,
MarkUnread
}

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.library
package app.omnivore.omnivore.feature.library
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem

View file

@ -0,0 +1,48 @@
package app.omnivore.omnivore.feature.library
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
@Composable
fun SavedItemLibraryContextMenu(
savedItemViewModel: SavedItemViewModel,
savedItem: SavedItem,
isExpanded: Boolean,
onDismiss: () -> Unit,
) {
val menuOptions = listOf(
if (savedItemViewModel.actionsMenuItemLiveData.value?.savedItem?.readingProgress == 100.0) {
MenuItemOption(R.string.saved_item_context_menu_action_mark_unread, SavedItemAction.MarkUnread)
} else {
MenuItemOption(R.string.saved_item_context_menu_action_mark_read, SavedItemAction.MarkRead)
}
)
DropdownMenu(
expanded = isExpanded,
onDismissRequest = onDismiss
) {
menuOptions.forEach { option ->
DropdownMenuItem(
text = { Text( text = stringResource(option.textResourceId), fontWeight = FontWeight.Normal) },
onClick = {
savedItemViewModel.handleSavedItemAction(savedItem.savedItemId, option.action)
onDismiss()
}
)
}
}
}
data class MenuItemOption(
val textResourceId: Int,
val action: SavedItemAction,
val customAction: (() -> Unit)? = null
)

View file

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

View file

@ -1,7 +1,7 @@
package app.omnivore.omnivore.ui.library
package app.omnivore.omnivore.feature.library
import androidx.lifecycle.MutableLiveData
import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
interface SavedItemViewModel {

View file

@ -0,0 +1,226 @@
package app.omnivore.omnivore.feature.library
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.core.database.entities.TypeaheadCardData
import app.omnivore.omnivore.feature.reader.PDFReaderActivity
import app.omnivore.omnivore.feature.reader.WebReaderLoadingContainerActivity
import app.omnivore.omnivore.feature.savedItemViews.SavedItemCard
import app.omnivore.omnivore.feature.savedItemViews.TypeaheadSearchCard
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchView(
viewModel: SearchViewModel,
navController: NavHostController
) {
val isRefreshing: Boolean by viewModel.isRefreshing.observeAsState(false)
val typeaheadMode: Boolean by viewModel.typeaheadMode.observeAsState(true)
val searchText: String by viewModel.searchTextLiveData.observeAsState("")
val actionsMenuItem: SavedItemWithLabelsAndHighlights? by viewModel.actionsMenuItemLiveData.observeAsState(
null
)
Scaffold(
topBar = {
TopAppBar(
title = { Text("") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
navigationIcon = {
if (actionsMenuItem != null) {
IconButton(onClick = {
viewModel.actionsMenuItemLiveData.postValue(null)
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
modifier = Modifier,
contentDescription = "Back"
)
}
}
},
actions = {
if (actionsMenuItem != null) {
IconButton(onClick = { }) {
Icon(
painter = painterResource(id = R.drawable.archive_outline),
contentDescription = null
)
}
IconButton(onClick = { }) {
Icon(
painter = painterResource(id = R.drawable.tag),
contentDescription = null
)
}
IconButton(onClick = { }) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = null
)
}
IconButton(onClick = { }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = null
)
}
} else {
Row {
SearchField(
searchText,
onSearch = {
viewModel.performSearch()
},
onSearchTextChanged = { viewModel.updateSearchText(it) },
navController = navController
)
}
}
}
)
}
) { paddingValues ->
if (isRefreshing) {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.padding(top = paddingValues.calculateTopPadding())
.fillMaxWidth()
) {
CircularProgressIndicator(
modifier = Modifier
.height(45.dp)
.width(45.dp),
strokeWidth = 5.dp,
color = colorResource(R.color.green_55B938)
)
}
} else if (typeaheadMode) {
TypeaheadSearchViewContent(
viewModel,
modifier = Modifier
.padding(top = paddingValues.calculateTopPadding())
)
} else {
SearchViewContent(
viewModel,
modifier = Modifier
.padding(top = paddingValues.calculateTopPadding())
)
}
}
}
@Composable
fun TypeaheadSearchViewContent(viewModel: SearchViewModel, modifier: Modifier) {
val context = LocalContext.current
val listState = rememberLazyListState()
val searchedCardsData: List<TypeaheadCardData> by viewModel.searchItemsLiveData.observeAsState(
listOf()
)
LazyColumn(
state = listState,
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.background(MaterialTheme.colorScheme.background)
.fillMaxSize()
) {
items(searchedCardsData) { cardData ->
TypeaheadSearchCard(
cardData = cardData,
onClickHandler = {
// val activityClass = if (cardData.isPDF()) PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java
val activityClass = WebReaderLoadingContainerActivity::class.java
val intent = Intent(context, activityClass)
intent.putExtra("SAVED_ITEM_SLUG", cardData.slug)
context.startActivity(intent)
},
actionHandler = { viewModel.handleSavedItemAction(cardData.savedItemId, it) }
)
}
}
}
@Composable
fun SearchViewContent(viewModel: SearchViewModel, modifier: Modifier) {
val context = LocalContext.current
val listState = rememberLazyListState()
val cardsData: List<SavedItemWithLabelsAndHighlights> by viewModel.itemsLiveData.observeAsState(
listOf()
)
LazyColumn(
state = listState,
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.background(MaterialTheme.colorScheme.background)
.fillMaxSize()
) {
items(cardsData) { cardDataWithLabels ->
SavedItemCard(
selected = false,
savedItemViewModel = viewModel,
savedItem = cardDataWithLabels,
onClickHandler = {
val activityClass =
if (cardDataWithLabels.savedItem.contentReader == "PDF") PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java
val intent = Intent(context, activityClass)
intent.putExtra("SAVED_ITEM_SLUG", cardDataWithLabels.savedItem.slug)
context.startActivity(intent)
},
actionHandler = {
viewModel.handleSavedItemAction(
cardDataWithLabels.savedItem.savedItemId,
it
)
}
)
}
}
}

View file

@ -1,16 +1,27 @@
package app.omnivore.omnivore.ui.library
package app.omnivore.omnivore.feature.library
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.*
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.core.data.archiveSavedItem
import app.omnivore.omnivore.core.data.deleteSavedItem
import app.omnivore.omnivore.core.data.isSavedItemContentStoredInDB
import app.omnivore.omnivore.core.data.librarySearch
import app.omnivore.omnivore.core.data.unarchiveSavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.core.database.entities.TypeaheadCardData
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.typeaheadSearch
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
@ -18,7 +29,7 @@ class SearchViewModel @Inject constructor(
private val networker: Networker,
private val dataService: DataService,
private val datastoreRepo: DatastoreRepository
): ViewModel(), SavedItemViewModel {
) : ViewModel(), SavedItemViewModel {
private val contentRequestChannel = Channel<String>(capacity = Channel.UNLIMITED)
private var cursor: String? = null
@ -66,7 +77,10 @@ class SearchViewModel @Inject constructor(
private fun loadUsingSearchAPI() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val result = dataService.librarySearch(cursor = librarySearchCursor, query = searchQueryString())
val result = dataService.librarySearch(
cursor = librarySearchCursor,
query = searchQueryString()
)
result.cursor?.let {
librarySearchCursor = it
}
@ -81,29 +95,29 @@ class SearchViewModel @Inject constructor(
}
val newItems = result.savedItems
/*
.map {
SavedItemCardDataWithLabels(
cardData = SavedItemCardData(
savedItemId = it.savedItem.savedItemId,
slug = it.savedItem.slug,
publisherURLString = it.savedItem.publisherURLString,
title = it.savedItem.title,
author = it.savedItem.author,
imageURLString = it.savedItem.imageURLString,
isArchived = it.savedItem.isArchived,
pageURLString = it.savedItem.pageURLString,
contentReader = it.savedItem.contentReader,
savedAt = it.savedItem.savedAt,
readingProgress = it.savedItem.readingProgress,
wordsCount = it.savedItem.wordsCount
),
labels = listOf()
)
}
*/
/*
.map {
SavedItemCardDataWithLabels(
cardData = SavedItemCardData(
savedItemId = it.savedItem.savedItemId,
slug = it.savedItem.slug,
publisherURLString = it.savedItem.publisherURLString,
title = it.savedItem.title,
author = it.savedItem.author,
imageURLString = it.savedItem.imageURLString,
isArchived = it.savedItem.isArchived,
pageURLString = it.savedItem.pageURLString,
contentReader = it.savedItem.contentReader,
savedAt = it.savedItem.savedAt,
readingProgress = it.savedItem.readingProgress,
wordsCount = it.savedItem.wordsCount
),
labels = listOf()
)
}
*/
itemsLiveData.value?.let{
itemsLiveData.value?.let {
itemsLiveData.postValue(newItems + it)
} ?: run {
itemsLiveData.postValue(newItems)
@ -146,22 +160,29 @@ class SearchViewModel @Inject constructor(
dataService.deleteSavedItem(itemID)
}
}
SavedItemAction.Archive -> {
viewModelScope.launch {
dataService.archiveSavedItem(itemID)
}
}
SavedItemAction.Unarchive -> {
viewModelScope.launch {
dataService.unarchiveSavedItem(itemID)
}
}
SavedItemAction.EditLabels -> {
// TODO
}
SavedItemAction.EditInfo -> {
// TODO
}
SavedItemAction.MarkRead -> TODO()
SavedItemAction.MarkUnread -> TODO()
}
actionsMenuItemLiveData.postValue(null)
}

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.notebook
package app.omnivore.omnivore.feature.notebook
import android.content.ClipData
import android.content.ClipboardManager
@ -6,7 +6,6 @@ import android.content.Context
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@ -36,11 +35,11 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.omnivore.omnivore.R
import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import dev.jeziellago.compose.markdowntext.MarkdownText
import kotlinx.coroutines.launch
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.feature.theme.OmnivoreTheme
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.res.stringResource

View file

@ -1,13 +1,13 @@
package app.omnivore.omnivore.ui.notebook
package app.omnivore.omnivore.feature.notebook
import androidx.lifecycle.*
import app.omnivore.omnivore.dataService.DataService
import app.omnivore.omnivore.dataService.createNoteHighlight
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.core.data.createNoteHighlight
import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput
import app.omnivore.omnivore.networking.Networker
import app.omnivore.omnivore.networking.updateHighlight
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.updateHighlight
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import com.apollographql.apollo3.api.Optional
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.reader
package app.omnivore.omnivore.feature.reader
import android.content.DialogInterface
import android.os.Bundle
@ -9,8 +9,8 @@ import android.view.WindowManager
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.DialogFragment
import app.omnivore.omnivore.ui.notebook.EditNoteModal
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
import app.omnivore.omnivore.feature.notebook.EditNoteModal
import app.omnivore.omnivore.feature.theme.OmnivoreTheme
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetDialog

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.reader
package app.omnivore.omnivore.feature.reader
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.reader
package app.omnivore.omnivore.feature.reader
import android.annotation.SuppressLint
import android.content.ClipData
@ -19,7 +19,7 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.lifecycle.Observer
import app.omnivore.omnivore.R
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.core.database.entities.Highlight
import com.pspdfkit.annotations.Annotation
import com.pspdfkit.annotations.HighlightAnnotation
import com.pspdfkit.configuration.PdfConfiguration

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.reader
package app.omnivore.omnivore.feature.reader
import android.content.Context
import android.net.Uri
@ -6,13 +6,20 @@ import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.dataService.DataService
import app.omnivore.omnivore.dataService.NanoId
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.ReadingProgressParams
import app.omnivore.omnivore.core.network.createHighlight
import app.omnivore.omnivore.core.network.deleteHighlights
import app.omnivore.omnivore.core.network.mergeHighlights
import app.omnivore.omnivore.core.network.savedItem
import app.omnivore.omnivore.core.network.updateHighlight
import app.omnivore.omnivore.core.network.updateReadingProgress
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.core.data.NanoId
import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput
import app.omnivore.omnivore.graphql.generated.type.MergeHighlightInput
import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput
import app.omnivore.omnivore.persistence.entities.SavedItem
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.core.database.entities.SavedItem
import com.apollographql.apollo3.api.Optional
import com.google.gson.Gson
import com.pspdfkit.annotations.Annotation

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.reader
package app.omnivore.omnivore.feature.reader
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.isSystemInDarkTheme
@ -24,7 +24,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
import app.omnivore.omnivore.feature.theme.OmnivoreTheme
@Composable
fun ReaderPreferencesView(webReaderViewModel: WebReaderViewModel) {

View file

@ -0,0 +1,361 @@
package app.omnivore.omnivore.feature.reader
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Color
import android.graphics.Rect
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.RoundRectShape
import android.os.Build
import android.util.Log
import android.view.*
import android.view.View.OnScrollChangeListener
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.viewinterop.AndroidView
import app.omnivore.omnivore.R
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.*
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun WebReader(
styledContent: String, webReaderViewModel: WebReaderViewModel, currentTheme: Themes?
) {
val javascriptActionLoopUUID: UUID by webReaderViewModel.javascriptActionLoopUUIDLiveData.observeAsState(
UUID.randomUUID()
)
val isDarkMode = isSystemInDarkTheme()
WebView.setWebContentsDebuggingEnabled(true)
Box {
AndroidView(factory = {
OmnivoreWebView(it).apply {
viewModel = webReaderViewModel
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
)
settings.javaScriptEnabled = true
settings.allowContentAccess = true
settings.allowFileAccess = true
settings.domStorageEnabled = true
alpha = 1.0f
viewModel?.showNavBar()
currentTheme?.let { theme ->
val bg = when (theme) {
Themes.SYSTEM -> {
if (isDarkMode) {
Color.BLACK
} else {
Color.WHITE
}
}
else -> {
theme.backgroundColor
}
}
setBackgroundColor(bg.toInt())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val scrollbarColor = when (theme) {
Themes.SYSTEM -> {
if (isDarkMode) {
Color.WHITE
} else {
Color.BLACK
}
}
else -> {
theme.scrollbarColor
}
}
val rad = 8f
val shape = ShapeDrawable(
RoundRectShape(
floatArrayOf(
rad, rad, rad, rad, rad, rad, rad, rad
), null, null
)
)
shape.paint.color = scrollbarColor.toInt()
verticalScrollbarThumbDrawable = shape
}
}
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?, request: WebResourceRequest?
): Boolean {
var handled: Boolean? = null
request?.let {
if (request.isForMainFrame && request.hasGesture() && viewModel != null) {
viewModel?.showOpenLinkSheet(context, request.url)
handled = true
}
}
return handled ?: super.shouldOverrideUrlLoading(view, request)
}
}
val javascriptInterface = AndroidWebKitMessenger { actionID, json ->
webReaderViewModel.hasTappedExistingHighlight = false
when (actionID) {
"userTap" -> {
val tapCoordinates = Gson().fromJson(json, TapCoordinates::class.java)
Log.d("wvt", "received tap action: $tapCoordinates")
CoroutineScope(Dispatchers.Main).launch {
webReaderViewModel.lastTapCoordinates = tapCoordinates
actionMode?.finish()
actionMode = null
}
}
"existingHighlightTap" -> {
val tapCoordinates = Gson().fromJson(json, TapCoordinates::class.java)
CoroutineScope(Dispatchers.Main).launch {
webReaderViewModel.hasTappedExistingHighlight = true
webReaderViewModel.lastTapCoordinates = tapCoordinates
startActionMode(null, ActionMode.TYPE_FLOATING)
}
}
"writeToClipboard" -> {
val quote = Gson().fromJson(json, HighlightQuote::class.java).quote
quote.let { unwrappedQuote ->
val clipboard =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(unwrappedQuote, unwrappedQuote)
clipboard.setPrimaryClip(clip)
}
}
else -> {
webReaderViewModel.handleIncomingWebMessage(actionID, json)
}
}
}
addJavascriptInterface(javascriptInterface, "AndroidWebKitMessenger")
loadDataWithBaseURL(
"file:///android_asset/",
styledContent,
"text/html; charset=utf-8",
"utf-8",
null
)
requestFocus()
setOnKeyListener { _, keyCode, event ->
if (event.action == KeyEvent.ACTION_DOWN) {
when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
scrollVertically(OmnivoreWebView.Direction.UP)
return@setOnKeyListener true
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
scrollVertically(OmnivoreWebView.Direction.DOWN)
return@setOnKeyListener true
}
}
}
// default value
false
}
}
}, update = {
if (javascriptActionLoopUUID != webReaderViewModel.lastJavascriptActionLoopUUID) {
for (script in webReaderViewModel.javascriptDispatchQueue) {
Log.d("js", "executing script: $script")
it.evaluateJavascript(script, null)
}
webReaderViewModel.resetJavascriptDispatchQueue()
}
})
}
}
class OmnivoreWebView(context: Context) : WebView(context), OnScrollChangeListener {
var viewModel: WebReaderViewModel? = null
var actionMode: ActionMode? = null
val density = resources.displayMetrics.density
init {
setOnScrollChangeListener(this)
}
enum class Direction(val value: Int) {
UP(-1), DOWN(1)
}
fun scrollVertically(direction: Direction, heightFactor: Int = 10) {
if (canScrollVertically(direction.value)) {
val scrollByValue = height.div(heightFactor)
scrollBy(0, direction.value.times(scrollByValue))
}
}
private val actionModeCallback = object : ActionMode.Callback2() {
// Called when the action mode is created; startActionMode() was called
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
actionMode = mode
if (viewModel?.hasTappedExistingHighlight == true) {
Log.d("wv", "inflating existing highlight menu")
mode.menuInflater.inflate(R.menu.highlight_selection_menu, menu)
} else {
viewModel?.showHighlightColorPalette()
mode.menuInflater.inflate(R.menu.text_selection_menu, menu)
}
return true
}
// Called each time the action mode is shown. Always called after onCreateActionMode, but
// may be called multiple times if the mode is invalidated.
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
return false // Return false if nothing is done
}
// Called when the user selects a contextual menu item
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.annotateHighlight, R.id.annotate -> {
val script = "var event = new Event('annotate');document.dispatchEvent(event);"
evaluateJavascript(script) {
mode.finish()
actionMode = null
}
true
}
R.id.highlight -> {
val script = "var event = new Event('highlight');document.dispatchEvent(event);"
evaluateJavascript(script) {
clearFocus()
mode.finish()
actionMode = null
}
true
}
R.id.copyHighlight -> {
val script =
"var event = new Event('copyHighlight');document.dispatchEvent(event);"
evaluateJavascript(script) {
clearFocus()
mode.finish()
actionMode = null
}
true
}
R.id.copyTextSelection -> {
val script =
"var event = new Event('copyTextSelection');document.dispatchEvent(event);"
evaluateJavascript(script) {
clearFocus()
mode.finish()
actionMode = null
}
true
}
R.id.removeHighlight -> {
val script = "var event = new Event('remove');document.dispatchEvent(event);"
evaluateJavascript(script) {
clearFocus()
mode.finish()
actionMode = null
}
true
}
else -> {
Log.d("Loggo", "${item.itemId} selected")
false
}
}
}
// Called when the user exits the action mode
override fun onDestroyActionMode(mode: ActionMode) {
Log.d("wv", "destroying menu: $mode")
viewModel?.hasTappedExistingHighlight = false
viewModel?.hideHighlightColorPalette()
actionMode = null
}
override fun onGetContentRect(mode: ActionMode?, view: View?, outRect: Rect?) {
Log.d("wv", "outRect: $outRect, View: $view")
if (viewModel?.lastTapCoordinates != null) {
val xValue = (viewModel!!.lastTapCoordinates!!.tapX * density).toInt()
val yValue = (viewModel!!.lastTapCoordinates!!.tapY * density).toInt()
val rect = Rect(xValue, yValue, xValue, yValue)
Log.d(
"wvt",
"setting rect based on last tapped rect: ${viewModel?.lastTapCoordinates.toString()}"
)
Log.d("wvt", "rect: $rect")
outRect?.set(rect)
} else {
outRect?.set(left, top, right, bottom)
}
}
}
override fun startActionMode(callback: ActionMode.Callback?): ActionMode {
Log.d("wv", "startActionMode:callback called")
return super.startActionMode(actionModeCallback)
}
override fun startActionModeForChild(
originalView: View?, callback: ActionMode.Callback?
): ActionMode {
Log.d("wv", "startActionMode:originalView:callback called")
return super.startActionModeForChild(originalView, actionModeCallback)
}
override fun startActionMode(callback: ActionMode.Callback?, type: Int): ActionMode {
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) {
@JavascriptInterface
fun handleIdentifiableMessage(actionID: String, jsonString: String) {
messageHandler(actionID, jsonString)
}
}
data class TapCoordinates(
val tapX: Double, val tapY: Double
)
data class HighlightQuote(val quote: String?)

View file

@ -1,8 +1,8 @@
package app.omnivore.omnivore.ui.reader
package app.omnivore.omnivore.feature.reader
import android.util.Log
import app.omnivore.omnivore.persistence.entities.SavedItem
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.Highlight
import com.google.gson.Gson
enum class WebFont(val displayText: String, val rawValue: String) {

View file

@ -0,0 +1,528 @@
package app.omnivore.omnivore.feature.reader
import android.content.Intent
import android.os.Bundle
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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetLayout
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import app.omnivore.omnivore.MainActivity
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.feature.components.LabelsSelectionSheetContent
import app.omnivore.omnivore.feature.components.LabelsViewModel
import app.omnivore.omnivore.feature.editinfo.EditInfoSheetContent
import app.omnivore.omnivore.feature.editinfo.EditInfoViewModel
import app.omnivore.omnivore.feature.notebook.EditNoteModal
import app.omnivore.omnivore.feature.notebook.NotebookView
import app.omnivore.omnivore.feature.notebook.NotebookViewModel
import app.omnivore.omnivore.feature.savedItemViews.SavedItemContextMenu
import app.omnivore.omnivore.feature.theme.OmnivoreTheme
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@AndroidEntryPoint
class WebReaderLoadingContainerActivity : ComponentActivity() {
val viewModel: WebReaderViewModel by viewModels()
private val notebookViewModel: NotebookViewModel by viewModels()
private val labelsViewModel: LabelsViewModel by viewModels()
private val editInfoViewModel: EditInfoViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val requestID = intent.getStringExtra("SAVED_ITEM_REQUEST_ID")
val slug = intent.getStringExtra("SAVED_ITEM_SLUG")
viewModel.loadItem(slug = slug, requestID = requestID)
setContent {
val systemUiController = rememberSystemUiController()
val useDarkIcons = !isSystemInDarkTheme()
DisposableEffect(systemUiController, useDarkIcons) {
systemUiController.setSystemBarsColor(
color = Color.Black,
darkIcons = false
)
onDispose {}
}
OmnivoreTheme {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = Color.Black)
) {
if (viewModel.hasFetchError.value == true) {
Text(stringResource(R.string.web_reader_loading_container_error_msg))
} else {
WebReaderLoadingContainer(
onLibraryIconTap = if (requestID != null) {
{ startMainActivity() }
} else null,
webReaderViewModel = viewModel,
notebookViewModel = notebookViewModel,
labelsViewModel = labelsViewModel,
editInfoViewModel = editInfoViewModel,
)
}
}
}
}
// animate the view up when keyboard appears
WindowCompat.setDecorFitsSystemWindows(window, false)
val rootView = findViewById<View>(android.R.id.content).rootView
ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets ->
val imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
rootView.setPadding(0, 0, 0, imeHeight)
insets
}
}
private fun startMainActivity() {
val intent = Intent(this, MainActivity::class.java)
this.startActivity(intent)
}
}
enum class BottomSheetState {
NONE,
PREFERENCES,
NOTEBOOK,
EDITNOTE,
HIGHLIGHTNOTE,
LABELS,
LINK,
EDIT_INFO,
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun WebReaderLoadingContainer(
onLibraryIconTap: (() -> Unit)? = null,
webReaderViewModel: WebReaderViewModel,
notebookViewModel: NotebookViewModel,
labelsViewModel: LabelsViewModel,
editInfoViewModel: EditInfoViewModel
) {
val currentThemeKey = webReaderViewModel.currentThemeKey.observeAsState()
val currentTheme = Themes.values().find { it.themeKey == currentThemeKey.value }
val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val bottomSheetState: BottomSheetState? by webReaderViewModel.bottomSheetStateLiveData.observeAsState(
BottomSheetState.NONE
)
val webReaderParams: WebReaderParams? by webReaderViewModel.webReaderParamsLiveData.observeAsState(
null
)
val shouldPopView: Boolean by webReaderViewModel.shouldPopViewLiveData.observeAsState(false)
val labels: List<SavedItemLabel> by webReaderViewModel.savedItemLabelsLiveData.observeAsState(
listOf()
)
val maxToolbarHeight = 48.dp
webReaderViewModel.maxToolbarHeightPx =
with(LocalDensity.current) { maxToolbarHeight.roundToPx().toFloat() }
val coroutineScope = rememberCoroutineScope()
val styledContent = webReaderParams?.let {
val webReaderContent = WebReaderContent(
preferences = webReaderViewModel.storedWebPreferences(isSystemInDarkTheme()),
item = it.item,
articleContent = it.articleContent,
)
webReaderContent.styledContent()
}
val modalBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
skipHalfExpanded = bottomSheetState == BottomSheetState.EDITNOTE || bottomSheetState == BottomSheetState.HIGHLIGHTNOTE,
confirmValueChange = {
if (it == ModalBottomSheetValue.Hidden) {
webReaderViewModel.resetBottomSheet()
}
true
}
)
val showMenu = {
coroutineScope.launch {
modalBottomSheetState.show()
}
}
when (bottomSheetState) {
BottomSheetState.PREFERENCES -> {
coroutineScope.launch {
if (!modalBottomSheetState.isVisible) {
modalBottomSheetState.show()
}
}
}
BottomSheetState.NOTEBOOK, BottomSheetState.EDITNOTE,
BottomSheetState.HIGHLIGHTNOTE, BottomSheetState.LABELS, BottomSheetState.EDIT_INFO,
BottomSheetState.LINK,
-> {
showMenu()
}
else -> {
coroutineScope.launch {
modalBottomSheetState.hide()
}
}
}
ModalBottomSheetLayout(
modifier = Modifier
.statusBarsPadding(),
sheetBackgroundColor = Color.Transparent,
sheetState = modalBottomSheetState,
sheetContent = {
when (bottomSheetState) {
BottomSheetState.PREFERENCES -> {
BottomSheetUI {
ReaderPreferencesView(webReaderViewModel)
}
}
BottomSheetState.NOTEBOOK -> {
webReaderParams?.let { params ->
BottomSheetUI {
NotebookView(
savedItemId = params.item.savedItemId,
viewModel = notebookViewModel,
onEditNote = {
notebookViewModel.highlightUnderEdit = it
webReaderViewModel.setBottomSheet(BottomSheetState.EDITNOTE)
})
}
}
}
BottomSheetState.EDITNOTE -> {
webReaderParams?.let { params ->
EditNoteModal(
initialValue = notebookViewModel.highlightUnderEdit?.annotation,
onDismiss = { save, note ->
coroutineScope.launch {
if (save) {
notebookViewModel.highlightUnderEdit?.let { highlight ->
notebookViewModel.updateHighlightNote(
highlight.highlightId,
note
)
} ?: run {
if (note != null) {
notebookViewModel.addArticleNote(
savedItemId = params.item.savedItemId,
note = note
)
}
}
}
notebookViewModel.highlightUnderEdit = null
}
webReaderViewModel.setBottomSheet(BottomSheetState.NOTEBOOK)
})
}
}
BottomSheetState.HIGHLIGHTNOTE -> {
EditNoteModal(
initialValue = webReaderViewModel.annotation,
onDismiss = { save, note ->
coroutineScope.launch {
if (save) {
webReaderViewModel.saveAnnotation(note ?: "")
} else {
webReaderViewModel.cancelAnnotation()
}
webReaderViewModel.annotation = null
}
webReaderViewModel.resetBottomSheet()
}
)
}
BottomSheetState.LABELS -> {
BottomSheetUI {
LabelsSelectionSheetContent(
labels = labels,
labelsViewModel = labelsViewModel,
initialSelectedLabels = webReaderParams?.labels ?: listOf(),
onCancel = {
coroutineScope.launch {
webReaderViewModel.resetBottomSheet()
}
},
isLibraryMode = false,
onSave = {
if (it != labels) {
webReaderViewModel.updateSavedItemLabels(
savedItemID = webReaderParams?.item?.savedItemId ?: "",
labels = it
)
}
coroutineScope.launch {
webReaderViewModel.resetBottomSheet()
}
},
onCreateLabel = { newLabelName, labelHexValue ->
webReaderViewModel.createNewSavedItemLabel(
newLabelName,
labelHexValue
)
}
)
}
}
BottomSheetState.EDIT_INFO -> {
BottomSheetUI {
EditInfoSheetContent(
savedItemId = webReaderParams?.item?.savedItemId,
title = webReaderParams?.item?.title,
author = webReaderParams?.item?.author,
description = webReaderParams?.item?.descriptionText,
viewModel = editInfoViewModel,
onCancel = {
coroutineScope.launch {
webReaderViewModel.resetBottomSheet()
}
},
onUpdated = {
coroutineScope.launch {
webReaderViewModel.updateItemTitle()
webReaderViewModel.resetBottomSheet()
}
}
)
}
}
BottomSheetState.LINK -> {
BottomSheetUI {
OpenLinkView(webReaderViewModel)
}
}
BottomSheetState.NONE -> {
}
else -> {
}
}
Spacer(modifier = Modifier.weight(1.0F))
}
) {
Scaffold(
topBar = {
ReaderTopAppBar(webReaderViewModel, onLibraryIconTap)
}) { paddingValues ->
if (styledContent != null) {
WebReader(
styledContent = styledContent,
webReaderViewModel = webReaderViewModel,
currentTheme = currentTheme,
)
}
LaunchedEffect(shouldPopView) {
if (shouldPopView) {
onBackPressedDispatcher?.onBackPressed()
}
}
}
}
}
@Composable
fun ReaderTopAppBar(
webReaderViewModel: WebReaderViewModel,
onLibraryIconTap: (() -> Unit)? = null
) {
val context = LocalContext.current
val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val isDarkMode = isSystemInDarkTheme()
val currentThemeKey = webReaderViewModel.currentThemeKey.observeAsState()
val currentTheme = Themes.values().find { it.themeKey == currentThemeKey.value }
val toolbarHeightPx: Float by webReaderViewModel.currentToolbarHeightLiveData.observeAsState(
0.0f
)
val webReaderParams: WebReaderParams? by webReaderViewModel.webReaderParamsLiveData.observeAsState(
null
)
var isMenuExpanded by remember { mutableStateOf(false) }
val themeBackgroundColor = currentTheme?.let {
if (it.themeKey == "System" && isDarkMode) {
Color(0xFF000000)
} else if (it.themeKey == "System") {
Color(0xFFFFFFFF)
} else {
Color(it.backgroundColor)
}
} ?: Color(0xFFFFFFFF)
val themeTintColor = currentTheme?.let {
if (it.themeKey == "System" && isDarkMode) {
Color(0xFFFFFFFF)
} else if (it.themeKey == "System") {
Color(0xFF000000)
} else {
Color(it.foregroundColor)
}
} ?: Color(0xFF000000)
TopAppBar(
modifier = Modifier
.height(height = with(LocalDensity.current) {
toolbarHeightPx.roundToInt().toDp()
}),
backgroundColor = themeBackgroundColor,
elevation = 0.dp,
title = {},
navigationIcon = {
IconButton(onClick = {
onBackPressedDispatcher?.onBackPressed()
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
modifier = Modifier,
contentDescription = "Back",
tint = themeTintColor
)
}
},
actions = {
if (onLibraryIconTap != null) {
IconButton(onClick = { onLibraryIconTap() }) {
Icon(
imageVector = Icons.Default.Home,
contentDescription = null,
tint = themeTintColor,
)
}
}
webReaderParams?.let {
IconButton(onClick = {
webReaderViewModel.setBottomSheet(BottomSheetState.NOTEBOOK)
}) {
Icon(
painter = painterResource(id = R.drawable.notebook),
contentDescription = null,
tint = themeTintColor
)
}
}
IconButton(onClick = {
webReaderViewModel.setBottomSheet(BottomSheetState.PREFERENCES)
}) {
Icon(
painter = painterResource(id = R.drawable.format_letter_case),
contentDescription = null,
tint = themeTintColor
)
}
IconButton(onClick = { isMenuExpanded = true }) {
Icon(
painter = painterResource(id = R.drawable.dots_horizontal),
contentDescription = null,
tint = themeTintColor
)
if (isMenuExpanded) {
webReaderParams?.let { params ->
SavedItemContextMenu(
context = context,
isExpanded = true,
isArchived = params.item.isArchived,
onDismiss = { isMenuExpanded = false },
webReaderViewModel = webReaderViewModel,
actionHandler = {
webReaderViewModel.handleSavedItemAction(
params.item.savedItemId,
it
)
}
)
}
}
}
},
)
}
@Composable
fun BottomSheetUI(content: @Composable () -> Unit) {
Box(
modifier = Modifier
.wrapContentHeight()
.fillMaxWidth()
.clip(RoundedCornerShape(topEnd = 20.dp, topStart = 20.dp))
.background(Color.White)
.statusBarsPadding()
) {
Scaffold { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) {
content()
}
}
}
}

View file

@ -0,0 +1,662 @@
package app.omnivore.omnivore.feature.reader
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.widget.Toast
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.utils.DatastoreKeys
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.core.analytics.EventTracker
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.core.data.archiveSavedItem
import app.omnivore.omnivore.core.data.createWebHighlight
import app.omnivore.omnivore.core.data.deleteHighlightFromJSON
import app.omnivore.omnivore.core.data.deleteSavedItem
import app.omnivore.omnivore.core.data.mergeWebHighlights
import app.omnivore.omnivore.core.data.unarchiveSavedItem
import app.omnivore.omnivore.core.data.updateWebHighlight
import app.omnivore.omnivore.core.data.updateWebReadingProgress
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.createNewLabel
import app.omnivore.omnivore.core.network.saveUrl
import app.omnivore.omnivore.core.network.savedItem
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.feature.components.HighlightColor
import app.omnivore.omnivore.feature.library.SavedItemAction
import app.omnivore.omnivore.feature.setSavedItemLabels
import com.apollographql.apollo3.api.Optional.Companion.presentIfNotNull
import com.google.gson.Gson
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
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<SavedItemLabel>
)
data class AnnotationWebViewMessage(
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),
}
@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<String> = mutableListOf()
var maxToolbarHeightPx = 0.0f
val webReaderParamsLiveData = MutableLiveData<WebReaderParams?>(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 hasTappedExistingHighlight = false
var lastTapCoordinates: TapCoordinates? = null
private var isLoading = false
private var slug: String? = null
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")
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 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 openCurrentLink(context: Context) {
currentLink?.let {
openLink(context, it)
}
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()
}
}
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
val articleContent = ArticleContent(
title = article.title,
htmlContent = article.content ?: "",
highlights = articleQueryResult.highlights,
contentStatus = articleQueryResult.state,
objectID = "",
labelsJSONString = Gson().toJson(articleQueryResult.labels)
)
return WebReaderParams(article, articleContent, articleQueryResult.labels)
}
fun handleSavedItemAction(itemID: String, action: SavedItemAction) {
when (action) {
SavedItemAction.Delete -> {
viewModelScope.launch {
dataService.deleteSavedItem(itemID)
popToLibraryView()
}
}
SavedItemAction.Archive -> {
viewModelScope.launch {
dataService.archiveSavedItem(itemID)
popToLibraryView()
}
}
SavedItemAction.Unarchive -> {
viewModelScope.launch {
dataService.unarchiveSavedItem(itemID)
popToLibraryView()
}
}
SavedItemAction.EditLabels -> {
bottomSheetStateLiveData.postValue(BottomSheetState.LABELS)
}
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.MarkUnread -> {
viewModelScope.launch {
dataService.updateWebReadingProgress(
jsonString = Gson().toJson(
mapOf(
"id" to itemID,
"readingProgressPercent" to 0,
"readingProgressAnchorIndex" to 0
)
)
)
}
}
}
}
private fun popToLibraryView() {
CoroutineScope(Dispatchers.Main).launch {
shouldPopViewLiveData.postValue(true)
}
}
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 {
// highlightColor.postValue(color)
// }
// }
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")
}
}
}
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<String> = 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)
}
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<SavedItemLabel>) {
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)
}
}
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))
}
}
}
}
}

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.root
package app.omnivore.omnivore.feature.root
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
@ -8,19 +8,19 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import app.omnivore.omnivore.Routes
import app.omnivore.omnivore.ui.auth.LoginViewModel
import app.omnivore.omnivore.ui.auth.WelcomeScreen
import app.omnivore.omnivore.ui.components.LabelsViewModel
import app.omnivore.omnivore.ui.editinfo.EditInfoViewModel
import app.omnivore.omnivore.ui.library.LibraryView
import app.omnivore.omnivore.ui.library.LibraryViewModel
import app.omnivore.omnivore.ui.library.SearchView
import app.omnivore.omnivore.ui.library.SearchViewModel
import app.omnivore.omnivore.ui.save.SaveViewModel
import app.omnivore.omnivore.ui.settings.PolicyWebView
import app.omnivore.omnivore.ui.settings.SettingsView
import app.omnivore.omnivore.ui.settings.SettingsViewModel
import app.omnivore.omnivore.navigation.Routes
import app.omnivore.omnivore.feature.auth.LoginViewModel
import app.omnivore.omnivore.feature.auth.WelcomeScreen
import app.omnivore.omnivore.feature.components.LabelsViewModel
import app.omnivore.omnivore.feature.editinfo.EditInfoViewModel
import app.omnivore.omnivore.feature.library.LibraryView
import app.omnivore.omnivore.feature.library.LibraryViewModel
import app.omnivore.omnivore.feature.library.SearchView
import app.omnivore.omnivore.feature.library.SearchViewModel
import app.omnivore.omnivore.feature.save.SaveViewModel
import app.omnivore.omnivore.feature.settings.PolicyWebView
import app.omnivore.omnivore.feature.settings.SettingsView
import app.omnivore.omnivore.feature.settings.SettingsViewModel
@Composable
fun RootView(

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.save
package app.omnivore.omnivore.feature.save
import android.content.Intent
import androidx.compose.foundation.background
@ -16,7 +16,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.reader.WebReaderLoadingContainerActivity
import app.omnivore.omnivore.feature.reader.WebReaderLoadingContainerActivity
import kotlinx.coroutines.launch
@Composable

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.save
package app.omnivore.omnivore.feature.save
import android.content.ContentValues
import android.content.Intent
@ -12,7 +12,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment.Companion.TopCenter

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.save
package app.omnivore.omnivore.feature.save
import android.util.Patterns
import androidx.compose.runtime.getValue
@ -8,13 +8,13 @@ import androidx.compose.ui.text.intl.Locale
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.Constants
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.DatastoreRepository
import app.omnivore.omnivore.utils.Constants
import app.omnivore.omnivore.utils.DatastoreKeys
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.R
import app.omnivore.omnivore.graphql.generated.SaveUrlMutation
import app.omnivore.omnivore.graphql.generated.type.SaveUrlInput
import app.omnivore.omnivore.ui.ResourceProvider
import app.omnivore.omnivore.feature.ResourceProvider
import com.apollographql.apollo3.ApolloClient
import com.apollographql.apollo3.api.Optional
import dagger.hilt.android.lifecycle.HiltViewModel

View file

@ -0,0 +1,288 @@
package app.omnivore.omnivore.feature.savedItemViews
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.toLowerCase
import androidx.compose.ui.text.toUpperCase
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.feature.components.LabelChip
import app.omnivore.omnivore.feature.components.LabelChipColors
import app.omnivore.omnivore.feature.library.SavedItemAction
import app.omnivore.omnivore.feature.library.SavedItemViewModel
import coil.compose.rememberAsyncImagePainter
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
fun SavedItemCard(
selected: Boolean,
savedItemViewModel: SavedItemViewModel,
savedItem: SavedItemWithLabelsAndHighlights,
onClickHandler: () -> Unit,
actionHandler: (SavedItemAction) -> Unit
) {
Column(
modifier = Modifier
.combinedClickable(onClick = onClickHandler, onLongClick = {
savedItemViewModel.actionsMenuItemLiveData.postValue(savedItem)
})
.background(if (selected) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.background)
.fillMaxWidth()
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
.background(Color.Transparent)
) {
Column(
verticalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier
.weight(1f, fill = false)
.padding(end = 20.dp)
.defaultMinSize(minHeight = 55.dp)
) {
ReadInfo(item = savedItem)
Text(
text = savedItem.savedItem.title, style = TextStyle(
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onBackground,
fontWeight = FontWeight.SemiBold
), maxLines = 2, lineHeight = 20.sp
)
if (savedItem.savedItem.author != null && savedItem.savedItem.author != "") {
Text(
text = byline(savedItem), style = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
color = Color(red = 137, green = 137, blue = 137)
), maxLines = 1, overflow = TextOverflow.Ellipsis
)
}
}
Image(
painter = rememberAsyncImagePainter(savedItem.savedItem.imageURLString),
contentDescription = "Image associated with saved item",
modifier = Modifier
.size(55.dp, 73.dp)
.clip(RoundedCornerShape(10.dp))
.defaultMinSize(minWidth = 55.dp, minHeight = 73.dp)
.clip(RoundedCornerShape(10.dp))
)
}
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
) {
savedItem.labels.filter { !isFlairLabel(it) }
.sortedWith(compareBy { it.name.toLowerCase(Locale.current) }).forEach { label ->
val chipColors = LabelChipColors.fromHex(label.color)
LabelChip(
modifier = Modifier.clickable { }, name = label.name, colors = chipColors
)
}
}
Divider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
}
}
fun byline(item: SavedItemWithLabelsAndHighlights): String {
item.savedItem.author?.let {
return item.savedItem.author
}
val publisherDisplayName = item.savedItem.publisherDisplayName()
publisherDisplayName?.let {
return publisherDisplayName
}
return ""
}
fun estimatedReadingTime(item: SavedItemWithLabelsAndHighlights): String {
item.savedItem.wordsCount?.let {
if (it > 0) {
val readLen = kotlin.math.max(1, it / 235)
return "$readLen MIN READ • "
}
}
return ""
}
fun readingProgress(item: SavedItemWithLabelsAndHighlights): String {
// If there is no wordsCount don't show progress because it will make no sense
item.savedItem.wordsCount?.let {
if (it > 0) {
val intVal = item.savedItem.readingProgress.toInt()
return "$intVal%"
}
}
return ""
}
//var highlightsText: String {
// item.hig ?.let {
// if let highlights = item.highlights, highlights.count > 0 {
// let fmted = LocalText.pluralizedText(key: "number_of_highlights", count: highlights.count)
// if item.wordsCount > 0 {
// return " • \(fmted)"
// }
// return fmted
// }
// return ""
//}
//
//var notesText: String {
// let notes = item.highlights?.filter { item in
// if let highlight = item as? Highlight {
// return !(highlight.annotation ?? "").isEmpty
// }
// return false
// }
//
// if let notes = notes, notes.count > 0 {
// let fmted = LocalText.pluralizedText(key: "number_of_notes", count: notes.count)
// if item.wordsCount > 0 {
// return " • \(fmted)"
// }
// return fmted
// }
// return ""
//}
enum class FlairIcon(
val rawValue: String, val sortOrder: Int
) {
FEED("feed", 0), RSS("rss", 0), FAVORITE("favorite", 1), NEWSLETTER(
"newsletter", 2
),
RECOMMENDED("recommended", 3), PINNED("pinned", 4)
}
val FLAIR_ICON_NAMES = listOf("feed", "rss", "favorite", "newsletter", "recommended", "pinned")
fun isFlairLabel(label: SavedItemLabel): Boolean {
return FLAIR_ICON_NAMES.contains(label.name.toLowerCase(Locale.current))
}
@Composable
fun flairIcons(item: SavedItemWithLabelsAndHighlights) {
val labels = item.labels.filter { isFlairLabel(it) }.map {
FlairIcon.valueOf(it.name.toUpperCase(Locale.current))
}
labels.forEach {
when (it) {
FlairIcon.RSS, FlairIcon.FEED -> {
Image(
painter = painterResource(id = R.drawable.flair_feed),
contentDescription = "Feed flair Icon",
modifier = Modifier.padding(end = 5.0.dp)
)
}
FlairIcon.FAVORITE -> {
Image(
painter = painterResource(id = R.drawable.flaire_favorite),
contentDescription = "Favorite flair Icon",
modifier = Modifier.padding(end = 5.0.dp)
)
}
FlairIcon.NEWSLETTER -> {
Image(
painter = painterResource(id = R.drawable.flair_newsletter),
contentDescription = "Newsletter flair Icon",
modifier = Modifier.padding(end = 5.0.dp)
)
}
FlairIcon.RECOMMENDED -> {
Image(
painter = painterResource(id = R.drawable.flair_recommended),
contentDescription = "Recommended flair Icon",
modifier = Modifier.padding(end = 5.0.dp)
)
}
FlairIcon.PINNED -> {
Image(
painter = painterResource(id = R.drawable.flair_pinned),
contentDescription = "Pinned flair Icon",
modifier = Modifier.padding(end = 5.0.dp)
)
}
}
}
}
@Composable
fun ReadInfo(item: SavedItemWithLabelsAndHighlights) {
Row(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 15.dp)
) {
// Show flair here
flairIcons(item)
Text(
text = estimatedReadingTime(item), style = TextStyle(
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
color = Color(red = 137, green = 137, blue = 137)
), maxLines = 1, overflow = TextOverflow.Ellipsis
)
Text(
text = readingProgress(item), style = TextStyle(
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
color = if (item.savedItem.readingProgress > 1) colorResource(R.color.green_55B938) else colorResource(
R.color.gray_898989
)
), maxLines = 1, overflow = TextOverflow.Ellipsis
)
}
}

View file

@ -0,0 +1,72 @@
package app.omnivore.omnivore.feature.savedItemViews
import android.content.Context
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import app.omnivore.omnivore.R
import app.omnivore.omnivore.feature.library.SavedItemAction
import app.omnivore.omnivore.feature.reader.WebReaderViewModel
@Composable
fun SavedItemContextMenu(
isExpanded: Boolean,
isArchived: Boolean,
context: Context,
webReaderViewModel: WebReaderViewModel,
onDismiss: () -> Unit,
actionHandler: (SavedItemAction) -> Unit
) {
val menuOptions = listOf(
MenuItemOption(R.string.saved_item_context_menu_action_edit_info, SavedItemAction.EditInfo),
MenuItemOption(
R.string.saved_item_context_menu_action_edit_labels,
SavedItemAction.EditLabels
),
MenuItemOption(
textResourceId = if (isArchived) R.string.saved_item_context_menu_action_unarchive else R.string.saved_item_context_menu_action_archive,
action = if (isArchived) SavedItemAction.Unarchive else SavedItemAction.Archive
),
MenuItemOption(
textResourceId = R.string.saved_item_context_menu_action_share_original,
customAction = {
webReaderViewModel.showShareLinkSheet(context)
onDismiss()
}
),
MenuItemOption(R.string.saved_item_context_menu_action_remove_item, SavedItemAction.Delete)
)
DropdownMenu(
expanded = isExpanded,
onDismissRequest = onDismiss
) {
menuOptions.forEach { option ->
if (option.customAction != null) {
DropdownMenuItem(
text = { Text( text = stringResource(option.textResourceId), fontWeight = FontWeight.Normal) },
onClick = {
option.customAction.invoke()
}
)
} else {
DropdownMenuItem(
text = { Text( text = stringResource(option.textResourceId), fontWeight = FontWeight.Normal) },
onClick = {
option.action?.let { actionHandler(it) }
onDismiss()
}
)
}
}
}
}
data class MenuItemOption(
val textResourceId: Int,
val action: SavedItemAction? = null,
val customAction: (() -> Unit)? = null
)

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.savedItemViews
package app.omnivore.omnivore.feature.savedItemViews
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@ -10,8 +10,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.*
import app.omnivore.omnivore.persistence.entities.TypeaheadCardData
import app.omnivore.omnivore.ui.library.SavedItemAction
import app.omnivore.omnivore.core.database.entities.TypeaheadCardData
import app.omnivore.omnivore.feature.library.SavedItemAction
@OptIn(ExperimentalFoundationApi::class)
@Composable

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.settings
package app.omnivore.omnivore.feature.settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button

View file

@ -0,0 +1,73 @@
package app.omnivore.omnivore.feature.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import app.omnivore.omnivore.R
@Composable
fun ManageAccountDialog(onDismiss: () -> Unit, settingsViewModel: SettingsViewModel) {
Dialog(onDismissRequest = { onDismiss() }) {
Surface(
shape = RoundedCornerShape(16.dp),
color = Color.White,
modifier = Modifier
.height(300.dp)
) {
ManageAccountView(settingsViewModel = settingsViewModel)
}
}
}
@Composable
fun ManageAccountView(settingsViewModel: SettingsViewModel) {
Column(
modifier = Modifier
.padding(top = 6.dp, start = 6.dp, end = 6.dp, bottom = 6.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.Center
) {
Text(stringResource(R.string.manage_account_title))
}
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(onClick = { settingsViewModel.resetDataCache() })
) {
Text(stringResource(R.string.manage_account_action_reset_data_cache))
Spacer(modifier = Modifier.weight(1.0F))
Icon(imageVector = Icons.Filled.Refresh, contentDescription = null)
}
}
}
}

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.ui.settings
package app.omnivore.omnivore.feature.settings
import android.annotation.SuppressLint
import android.view.ViewGroup
@ -14,7 +14,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
import androidx.navigation.NavHostController
import app.omnivore.omnivore.Routes
import app.omnivore.omnivore.navigation.Routes
import app.omnivore.omnivore.R
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "SetJavaScriptEnabled")

Some files were not shown because too many files have changed in this diff Show more