mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1838 from omnivore-app/feature/android-labels
Label Filtering - Android
This commit is contained in:
commit
0962ae3deb
45 changed files with 1586 additions and 137 deletions
|
|
@ -17,8 +17,8 @@ android {
|
|||
applicationId "app.omnivore.omnivore"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 26
|
||||
versionName "0.0.26"
|
||||
versionCode 33
|
||||
versionName "0.0.33"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
12
android/Omnivore/app/src/main/graphql/ApplyLabels.graphql
Normal file
12
android/Omnivore/app/src/main/graphql/ApplyLabels.graphql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
mutation SetLabels($input: SetLabelsInput!) {
|
||||
setLabels(input: $input) {
|
||||
... on SetLabelsSuccess {
|
||||
labels {
|
||||
...LabelFields
|
||||
}
|
||||
}
|
||||
... on SetLabelsError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
16
android/Omnivore/app/src/main/graphql/CreateLabel.graphql
Normal file
16
android/Omnivore/app/src/main/graphql/CreateLabel.graphql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
mutation CreateLabel($input: CreateLabelInput!) {
|
||||
createLabel(input: $input) {
|
||||
... on CreateLabelSuccess {
|
||||
label {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
... on CreateLabelError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
12
android/Omnivore/app/src/main/graphql/Labels.graphql
Normal file
12
android/Omnivore/app/src/main/graphql/Labels.graphql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
query GetLabels {
|
||||
labels {
|
||||
... on LabelsSuccess {
|
||||
labels {
|
||||
...LabelFields
|
||||
}
|
||||
}
|
||||
... on LabelsError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ object DatastoreKeys {
|
|||
const val prefersWebHighContrastText = "prefersWebHighContrastText"
|
||||
const val lastUsedSavedItemFilter = "lastUsedSavedItemFilter"
|
||||
const val lastUsedSavedItemSortFilter = "lastUsedSavedItemSortFilter"
|
||||
const val preferredTheme = "preferredTheme"
|
||||
}
|
||||
|
||||
object AppleConstants {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import androidx.core.view.WindowInsetsCompat
|
|||
import app.omnivore.omnivore.ui.auth.LoginViewModel
|
||||
import app.omnivore.omnivore.ui.library.LibraryViewModel
|
||||
import app.omnivore.omnivore.ui.root.RootView
|
||||
import app.omnivore.omnivore.ui.settings.SettingsViewModel
|
||||
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
|
||||
import com.pspdfkit.PSPDFKit
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
|
@ -31,6 +32,7 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
val loginViewModel: LoginViewModel by viewModels()
|
||||
val libraryViewModel: LibraryViewModel by viewModels()
|
||||
val settingsViewModel: SettingsViewModel by viewModels()
|
||||
|
||||
val context = this
|
||||
|
||||
|
|
@ -51,7 +53,7 @@ class MainActivity : ComponentActivity() {
|
|||
.fillMaxSize()
|
||||
.background(color = Color.Black)
|
||||
) {
|
||||
RootView(loginViewModel, libraryViewModel)
|
||||
RootView(loginViewModel, libraryViewModel, settingsViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,4 +3,7 @@ package app.omnivore.omnivore
|
|||
sealed class Routes(val route: String) {
|
||||
object Library : Routes("Library")
|
||||
object Settings: Routes("Settings")
|
||||
object Documentation: Routes("Documentation")
|
||||
object PrivacyPolicy: Routes("PrivacyPolicy")
|
||||
object TermsAndConditions: Routes("TermsAndConditions")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,17 +4,31 @@ 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 kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import javax.inject.Inject
|
||||
|
||||
class DataService @Inject constructor(
|
||||
context: Context,
|
||||
val networker: Networker
|
||||
) {
|
||||
val savedItemSyncChannel = Channel<SavedItem>(capacity = Channel.UNLIMITED)
|
||||
val highlightSyncChannel = Channel<Highlight>(capacity = Channel.UNLIMITED)
|
||||
|
||||
val db = Room.databaseBuilder(
|
||||
context,
|
||||
AppDatabase::class.java, "omnivore-database"
|
||||
).build()
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
|
||||
init {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
startSyncChannels()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearDatabase() {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,12 @@ fun DataService.libraryLiveData(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (labels.isNotEmpty()) {
|
||||
mediatorLiveData.value = (mediatorLiveData.value ?: listOf()).filter {
|
||||
it.labels.intersect(labels.toSet()).any()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mediatorLiveData
|
||||
|
|
|
|||
|
|
@ -4,6 +4,41 @@ import android.util.Log
|
|||
import app.omnivore.omnivore.networking.*
|
||||
import app.omnivore.omnivore.persistence.entities.*
|
||||
|
||||
suspend fun DataService.librarySearch(cursor: String?, query: String): SavedItemSyncResult {
|
||||
val searchResult = networker.search(cursor = cursor, limit = 10, query = query)
|
||||
|
||||
val savedItems = searchResult.items.map { it.item }
|
||||
|
||||
db.savedItemDao().insertAll(savedItems)
|
||||
|
||||
val labels: MutableList<SavedItemLabel> = mutableListOf()
|
||||
val crossRefs: MutableList<SavedItemAndSavedItemLabelCrossRef> = mutableListOf()
|
||||
|
||||
// save labels
|
||||
for (searchItem in searchResult.items) {
|
||||
labels.addAll(searchItem.labels)
|
||||
|
||||
val newCrossRefs = searchItem.labels.map {
|
||||
SavedItemAndSavedItemLabelCrossRef(savedItemLabelId = it.savedItemLabelId, savedItemId = searchItem.item.savedItemId)
|
||||
}
|
||||
|
||||
crossRefs.addAll(newCrossRefs)
|
||||
}
|
||||
|
||||
db.savedItemLabelDao().insertAll(labels)
|
||||
db.savedItemAndSavedItemLabelCrossRefDao().insertAll(crossRefs)
|
||||
|
||||
Log.d("sync", "found ${searchResult.items.size} items with search api. Query: $query")
|
||||
|
||||
return SavedItemSyncResult(
|
||||
hasError = false,
|
||||
hasMoreItems = false,
|
||||
cursor = searchResult.cursor,
|
||||
count = searchResult.items.size,
|
||||
savedItemSlugs = savedItems.map { it.slug }
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun DataService.sync(since: String, cursor: String?, limit: Int = 20): SavedItemSyncResult {
|
||||
val syncResult = networker.savedItemUpdates(cursor = cursor, limit = limit, since = since) ?: return SavedItemSyncResult.errorResult
|
||||
|
||||
|
|
@ -60,6 +95,8 @@ suspend fun DataService.sync(since: String, cursor: String?, limit: Int = 20): S
|
|||
db.savedItemLabelDao().insertAll(labels)
|
||||
db.savedItemAndSavedItemLabelCrossRefDao().insertAll(crossRefs)
|
||||
|
||||
Log.d("sync", "found ${syncResult.items.size} items with sync api. Since: $since")
|
||||
|
||||
return SavedItemSyncResult(
|
||||
hasError = false,
|
||||
hasMoreItems = syncResult.hasMoreItems,
|
||||
|
|
@ -69,8 +106,15 @@ suspend fun DataService.sync(since: String, cursor: String?, limit: Int = 20): S
|
|||
)
|
||||
}
|
||||
|
||||
suspend fun DataService.syncSavedItemContent(slug: String) {
|
||||
fun DataService.isSavedItemContentStoredInDB(slug: String): Boolean {
|
||||
val existingItem = db.savedItemDao().getSavedItemWithLabelsAndHighlights(slug)
|
||||
val content = existingItem?.savedItem?.content ?: ""
|
||||
return content.length > 10
|
||||
}
|
||||
|
||||
suspend fun DataService.fetchSavedItemContent(slug: String) {
|
||||
val syncResult = networker.savedItem(slug)
|
||||
val isSuccess = syncResult.item != null
|
||||
|
||||
val savedItem = syncResult.item ?: return
|
||||
db.savedItemDao().insert(savedItem)
|
||||
|
|
@ -92,10 +136,9 @@ suspend fun DataService.syncSavedItemContent(slug: String) {
|
|||
}
|
||||
|
||||
db.savedItemAndHighlightCrossRefDao().insertAll(highlightCrossRefs)
|
||||
|
||||
Log.d("sync", "saved content for item with id: ${savedItem.savedItemId}")
|
||||
}
|
||||
|
||||
|
||||
data class SavedItemSyncResult(
|
||||
val hasError: Boolean,
|
||||
val hasMoreItems: Boolean,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package app.omnivore.omnivore.dataService
|
||||
|
||||
import app.omnivore.omnivore.networking.savedItemLabels
|
||||
|
||||
suspend fun DataService.syncLabels() {
|
||||
val fetchedLabels = networker.savedItemLabels()
|
||||
db.savedItemLabelDao().insertAll(fetchedLabels)
|
||||
}
|
||||
|
|
@ -7,17 +7,33 @@ import app.omnivore.omnivore.networking.*
|
|||
import app.omnivore.omnivore.persistence.entities.Highlight
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItem
|
||||
import com.apollographql.apollo3.api.Optional
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
suspend fun DataService.startSyncChannels() {
|
||||
for (savedItem in savedItemSyncChannel) {
|
||||
syncSavedItem(savedItem)
|
||||
}
|
||||
|
||||
for (highlight in highlightSyncChannel) {
|
||||
syncHighlight(highlight)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun DataService.syncOfflineItemsWithServerIfNeeded() {
|
||||
val unSyncedSavedItems = db.savedItemDao().getUnSynced()
|
||||
val unSyncedHighlights = db.highlightDao().getUnSynced()
|
||||
|
||||
for (savedItem in unSyncedSavedItems) {
|
||||
syncSavedItem(savedItem)
|
||||
delay(250)
|
||||
savedItemSyncChannel.send(savedItem)
|
||||
}
|
||||
|
||||
for (highlight in unSyncedHighlights) {
|
||||
syncHighlight(highlight)
|
||||
delay(250)
|
||||
highlightSyncChannel.send(highlight)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
package app.omnivore.omnivore.networking
|
||||
|
||||
import app.omnivore.omnivore.graphql.generated.CreateLabelMutation
|
||||
import app.omnivore.omnivore.graphql.generated.SetLabelsMutation
|
||||
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
|
||||
import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput
|
||||
|
||||
suspend fun Networker.updateLabelsForSavedItem(input: SetLabelsInput): Boolean {
|
||||
return try {
|
||||
val result = authenticatedApolloClient().mutation(SetLabelsMutation(input)).execute()
|
||||
return result.data?.setLabels?.onSetLabelsSuccess?.labels != null
|
||||
} catch (e: java.lang.Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Networker.createNewLabel(input: CreateLabelInput): CreateLabelMutation.Label? {
|
||||
return try {
|
||||
val result = authenticatedApolloClient().mutation(CreateLabelMutation(input)).execute()
|
||||
return result.data?.createLabel?.onCreateLabelSuccess?.label
|
||||
} catch (e: java.lang.Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package app.omnivore.omnivore.networking
|
||||
|
||||
import app.omnivore.omnivore.graphql.generated.GetLabelsQuery
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
|
||||
|
||||
|
||||
suspend fun Networker.savedItemLabels(): List<SavedItemLabel> {
|
||||
try {
|
||||
val result = authenticatedApolloClient().query(GetLabelsQuery()).execute()
|
||||
val labels = result.data?.labels?.onLabelsSuccess?.labels ?: listOf()
|
||||
|
||||
return labels.map {
|
||||
SavedItemLabel(
|
||||
savedItemLabelId = it.labelFields.id,
|
||||
name = it.labelFields.name,
|
||||
color = it.labelFields.color,
|
||||
createdAt = it.labelFields.createdAt as String?,
|
||||
labelDescription = it.labelFields.description
|
||||
)
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
return listOf()
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@ package app.omnivore.omnivore.networking
|
|||
|
||||
import app.omnivore.omnivore.graphql.generated.SearchQuery
|
||||
import app.omnivore.omnivore.graphql.generated.TypeaheadSearchQuery
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItem
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemCardData
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
|
||||
import com.apollographql.apollo3.api.Optional
|
||||
|
||||
data class SearchQueryResponse(
|
||||
|
|
@ -10,6 +12,16 @@ data class SearchQueryResponse(
|
|||
val cardsData: List<SavedItemCardData>
|
||||
)
|
||||
|
||||
data class LibrarySearchQueryResponse(
|
||||
val cursor: String?,
|
||||
val items: List<LibrarySearchItem>
|
||||
)
|
||||
|
||||
data class LibrarySearchItem(
|
||||
val item: SavedItem,
|
||||
val labels: List<SavedItemLabel>
|
||||
)
|
||||
|
||||
suspend fun Networker.typeaheadSearch(
|
||||
query: String
|
||||
): SearchQueryResponse {
|
||||
|
|
@ -44,7 +56,7 @@ suspend fun Networker.search(
|
|||
cursor: String? = null,
|
||||
limit: Int = 15,
|
||||
query: String
|
||||
): SearchQueryResponse {
|
||||
): LibrarySearchQueryResponse {
|
||||
try {
|
||||
val result = authenticatedApolloClient().query(
|
||||
SearchQuery(
|
||||
|
|
@ -57,22 +69,46 @@ suspend fun Networker.search(
|
|||
val newCursor = result.data?.search?.onSearchSuccess?.pageInfo?.endCursor
|
||||
val itemList = result.data?.search?.onSearchSuccess?.edges ?: listOf()
|
||||
|
||||
val cardsData = itemList.map {
|
||||
SavedItemCardData(
|
||||
savedItemId = it.node.id,
|
||||
slug = it.node.slug,
|
||||
publisherURLString = it.node.originalArticleUrl,
|
||||
title = it.node.title,
|
||||
author = it.node.author,
|
||||
imageURLString = it.node.image,
|
||||
isArchived = it.node.isArchived,
|
||||
pageURLString = it.node.url,
|
||||
contentReader = it.node.contentReader.rawValue,
|
||||
val searchItems = itemList.map {
|
||||
LibrarySearchItem(
|
||||
item = SavedItem(
|
||||
savedItemId = it.node.id,
|
||||
title = it.node.title,
|
||||
createdAt = it.node.createdAt as String,
|
||||
savedAt = it.node.savedAt as String,
|
||||
readAt = it.node.readAt as String?,
|
||||
updatedAt = it.node.updatedAt as String?,
|
||||
readingProgress = it.node.readingProgressPercent,
|
||||
readingProgressAnchor = it.node.readingProgressAnchorIndex,
|
||||
imageURLString = it.node.image,
|
||||
pageURLString = it.node.url,
|
||||
descriptionText = it.node.description,
|
||||
publisherURLString = it.node.originalArticleUrl,
|
||||
siteName = it.node.siteName,
|
||||
author = it.node.author,
|
||||
publishDate = it.node.publishedAt as String?,
|
||||
slug = it.node.slug,
|
||||
isArchived = it.node.isArchived,
|
||||
contentReader = it.node.contentReader.rawValue,
|
||||
content = null
|
||||
),
|
||||
labels = (it.node.labels ?: listOf()).map { label ->
|
||||
SavedItemLabel(
|
||||
savedItemLabelId = label.id,
|
||||
name = label.name,
|
||||
color = label.color,
|
||||
createdAt = null,
|
||||
labelDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return SearchQueryResponse(newCursor, cardsData)
|
||||
return LibrarySearchQueryResponse(
|
||||
cursor = newCursor,
|
||||
items = searchItems
|
||||
)
|
||||
} catch (e: java.lang.Exception) {
|
||||
return SearchQueryResponse(null, listOf())
|
||||
return LibrarySearchQueryResponse(null, listOf())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import app.omnivore.omnivore.persistence.entities.*
|
|||
SavedItemAndSavedItemLabelCrossRef::class,
|
||||
SavedItemAndHighlightCrossRef::class
|
||||
],
|
||||
version = 2
|
||||
version = 3
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun viewerDao(): ViewerDao
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package app.omnivore.omnivore.persistence.entities
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.room.*
|
||||
|
||||
@Entity
|
||||
|
|
@ -16,6 +17,10 @@ data class SavedItemLabel(
|
|||
interface SavedItemLabelDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertAll(items: List<SavedItemLabel>)
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM SavedItemLabel WHERE serverSyncStatus != 2 ORDER BY name ASC")
|
||||
fun getSavedItemLabelsLiveData(): LiveData<List<SavedItemLabel>>
|
||||
}
|
||||
|
||||
@Entity(
|
||||
|
|
@ -30,8 +35,7 @@ interface SavedItemLabelDao {
|
|||
ForeignKey(
|
||||
entity = SavedItemLabel::class,
|
||||
parentColumns = arrayOf("savedItemLabelId"),
|
||||
childColumns = arrayOf("savedItemLabelId"),
|
||||
onDelete = ForeignKey.CASCADE
|
||||
childColumns = arrayOf("savedItemLabelId")
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
@ -64,6 +68,9 @@ data class SavedItemCardDataWithLabels(
|
|||
interface SavedItemAndSavedItemLabelCrossRefDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertAll(items: List<SavedItemAndSavedItemLabelCrossRef>)
|
||||
|
||||
@Query("DELETE FROM savedItemAndSavedItemLabelCrossRef WHERE savedItemId = :savedItemId")
|
||||
fun deleteRefsBySavedItemId(savedItemId: String)
|
||||
}
|
||||
|
||||
// has many highlights
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
package app.omnivore.omnivore.ui.components
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
|
||||
data class LabelChipColors(
|
||||
val textColor: Color,
|
||||
val containerColor: Color
|
||||
) {
|
||||
companion object {
|
||||
fun fromHex(hex: String): LabelChipColors {
|
||||
val labelColor = Color(android.graphics.Color.parseColor(hex))
|
||||
|
||||
return LabelChipColors(
|
||||
textColor = if (labelColor.luminance() > 0.5) Color.Black else Color.White,
|
||||
containerColor = labelColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
package app.omnivore.omnivore.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LabelCreationDialog(onDismiss: () -> Unit, onSave: (String, String) -> Unit) {
|
||||
var labelName by rememberSaveable { mutableStateOf("") }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val swatchHexes = LabelSwatchHelper.allHexes()
|
||||
var selectedHex by rememberSaveable { mutableStateOf(swatchHexes.first()) }
|
||||
|
||||
Dialog(onDismissRequest = { onDismiss() }) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Top,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 6.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = "Cancel")
|
||||
}
|
||||
|
||||
Text("Create New Label", fontWeight = FontWeight.ExtraBold)
|
||||
|
||||
TextButton(onClick = { onSave(labelName, selectedHex) }) {
|
||||
Text(text = "Create")
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp)
|
||||
) {
|
||||
Text("Assign a name and color.")
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = labelName,
|
||||
placeholder = { Text(text = "Label Name") },
|
||||
onValueChange = { labelName = it },
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier
|
||||
.height(130.dp)
|
||||
.padding(10.dp)
|
||||
) {
|
||||
LazyHorizontalGrid(
|
||||
rows = GridCells.Fixed(2),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
items(swatchHexes) { hex ->
|
||||
val labelChipColors = LabelChipColors.fromHex(hex)
|
||||
val borderThickness = if (selectedHex == hex) 2.dp else 0.dp
|
||||
OutlinedButton(
|
||||
onClick = { selectedHex = hex },
|
||||
modifier= Modifier
|
||||
.size(50.dp),
|
||||
shape = CircleShape,
|
||||
border= BorderStroke(borderThickness, Color.Black),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(containerColor = labelChipColors.containerColor),
|
||||
content = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object LabelSwatchHelper {
|
||||
fun allHexes(): List<String> {
|
||||
val shuffledSwatches = swatchHexes.shuffled()
|
||||
return listOf(shuffledSwatches.last()) + webSwatchHexes + shuffledSwatches.dropLast(1)
|
||||
}
|
||||
|
||||
private val webSwatchHexes = listOf(
|
||||
"#FF5D99",
|
||||
"#7CFF7B",
|
||||
"#FFD234",
|
||||
"#7BE4FF",
|
||||
"#CE88EF",
|
||||
"#EF8C43"
|
||||
)
|
||||
|
||||
private val swatchHexes = listOf(
|
||||
"#fff034",
|
||||
"#efff34",
|
||||
"#d1ff34",
|
||||
"#b2ff34",
|
||||
"#94ff34",
|
||||
"#75ff34",
|
||||
"#57ff34",
|
||||
"#38ff34",
|
||||
"#34ff4e",
|
||||
"#34ff6d",
|
||||
"#34ff8b",
|
||||
"#34ffa9",
|
||||
"#34ffc8",
|
||||
"#34ffe6",
|
||||
"#34f9ff",
|
||||
"#34dbff",
|
||||
"#34bcff",
|
||||
"#349eff",
|
||||
"#347fff",
|
||||
"#3461ff",
|
||||
"#3443ff",
|
||||
"#4434ff",
|
||||
"#6234ff",
|
||||
"#8134ff",
|
||||
"#9f34ff",
|
||||
"#be34ff",
|
||||
"#dc34ff",
|
||||
"#fb34ff",
|
||||
"#ff34e5",
|
||||
"#ff34c7",
|
||||
"#ff34a8",
|
||||
"#ff348a",
|
||||
"#ff346b"
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
package app.omnivore.omnivore.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddCircle
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
|
||||
import app.omnivore.omnivore.ui.library.LibraryViewModel
|
||||
import app.omnivore.omnivore.ui.reader.WebReaderParams
|
||||
import app.omnivore.omnivore.ui.reader.WebReaderViewModel
|
||||
|
||||
@Composable
|
||||
fun WebReaderLabelsSelectionSheet(viewModel: WebReaderViewModel) {
|
||||
val isActive: Boolean by viewModel.showLabelsSelectionSheetLiveData.observeAsState(false)
|
||||
val labels: List<SavedItemLabel> by viewModel.savedItemLabelsLiveData.observeAsState(listOf())
|
||||
val webReaderParams: WebReaderParams? by viewModel.webReaderParamsLiveData.observeAsState(null)
|
||||
|
||||
if (isActive) {
|
||||
Dialog(onDismissRequest = {
|
||||
viewModel.showLabelsSelectionSheetLiveData.value = false
|
||||
} ) {
|
||||
LabelsSelectionSheetContent(
|
||||
labels = labels,
|
||||
initialSelectedLabels = webReaderParams?.labels ?: listOf(),
|
||||
onCancel = {
|
||||
viewModel.showLabelsSelectionSheetLiveData.value = false
|
||||
},
|
||||
isLibraryMode = false,
|
||||
onSave = {
|
||||
if (it != labels) {
|
||||
viewModel.updateSavedItemLabels(savedItemID = webReaderParams?.item?.savedItemId ?: "", labels = it)
|
||||
}
|
||||
viewModel.showLabelsSelectionSheetLiveData.value = false
|
||||
},
|
||||
onCreateLabel = { newLabelName, labelHexValue ->
|
||||
viewModel.createNewSavedItemLabel(newLabelName, labelHexValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
|
||||
if (isActive) {
|
||||
Dialog(onDismissRequest = {
|
||||
viewModel.labelsSelectionCurrentItemLiveData.value = null
|
||||
viewModel.showLabelsSelectionSheetLiveData.value = false
|
||||
} ) {
|
||||
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.cardData.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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LabelsSelectionSheetContent(
|
||||
isLibraryMode: Boolean,
|
||||
labels: List<SavedItemLabel>,
|
||||
initialSelectedLabels: List<SavedItemLabel>,
|
||||
onCancel: () -> Unit,
|
||||
onSave: (List<SavedItemLabel>) -> Unit,
|
||||
onCreateLabel: (String, String) -> Unit
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val selectedLabels = remember { mutableStateOf(initialSelectedLabels) }
|
||||
var showCreateLabelDialog by remember { mutableStateOf(false ) }
|
||||
|
||||
val titleText = if (isLibraryMode) "Filter by Label" else "Set Labels"
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
|
||||
if (showCreateLabelDialog) {
|
||||
LabelCreationDialog(
|
||||
onDismiss = { showCreateLabelDialog = false },
|
||||
onSave = { labelName, hexColor ->
|
||||
onCreateLabel(labelName, hexColor)
|
||||
showCreateLabelDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Top,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 6.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
TextButton(onClick = onCancel) {
|
||||
Text(text = "Cancel")
|
||||
}
|
||||
|
||||
Text(titleText, fontWeight = FontWeight.ExtraBold)
|
||||
|
||||
TextButton(onClick = { onSave(selectedLabels.value) }) {
|
||||
Text(text = "Done")
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
items(labels) { label ->
|
||||
val isLabelSelected = selectedLabels.value.contains(label)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
if (isLabelSelected) {
|
||||
selectedLabels.value =
|
||||
selectedLabels.value.filter { it.savedItemLabelId != label.savedItemLabelId }
|
||||
} else {
|
||||
selectedLabels.value = selectedLabels.value + listOf(label)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 6.dp)
|
||||
) {
|
||||
val chipColors = LabelChipColors.fromHex(label.color)
|
||||
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = { Text(label.name) },
|
||||
border = null,
|
||||
colors = SuggestionChipDefaults.elevatedSuggestionChipColors(
|
||||
containerColor = chipColors.containerColor,
|
||||
labelColor = chipColors.textColor,
|
||||
iconContentColor = chipColors.textColor
|
||||
)
|
||||
)
|
||||
if (isLabelSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
Divider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
|
||||
}
|
||||
|
||||
if (!isLibraryMode) {
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { showCreateLabelDialog = true }
|
||||
.padding(horizontal = 6.dp)
|
||||
.padding(vertical = 12.dp)
|
||||
)
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Filled.AddCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text(text = "Create a new Label")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package app.omnivore.omnivore.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
|
||||
// https://gist.githubusercontent.com/manojbhadane/afe14d552a520bca83f80eef22dacade/raw/1427fc4d0e3ccbc30484976fc1c1424c2fa613f3/jc-sc-1.kt
|
||||
|
||||
@Composable
|
||||
fun SegmentedControl(
|
||||
items: List<String>,
|
||||
initialSelectedItemIndex: Int,
|
||||
cornerRadius : Int = 10,
|
||||
onItemSelection: (selectedItemIndex: Int) -> Unit
|
||||
) {
|
||||
val selectedIndex = remember { mutableStateOf(initialSelectedItemIndex) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
) {
|
||||
items.forEachIndexed { index, item ->
|
||||
OutlinedButton(
|
||||
modifier = Modifier
|
||||
.wrapContentSize()
|
||||
.offset((-1 * index).dp, 0.dp)
|
||||
.zIndex(if (selectedIndex.value == index) 1f else 0f),
|
||||
onClick = {
|
||||
selectedIndex.value = index
|
||||
onItemSelection(selectedIndex.value)
|
||||
},
|
||||
shape = when (index) {
|
||||
/**
|
||||
* left outer button
|
||||
*/
|
||||
0 -> RoundedCornerShape(
|
||||
topStartPercent = cornerRadius,
|
||||
topEndPercent = 0,
|
||||
bottomStartPercent = cornerRadius,
|
||||
bottomEndPercent = 0
|
||||
)
|
||||
/**
|
||||
* right outer button
|
||||
*/
|
||||
items.size - 1 -> RoundedCornerShape(
|
||||
topStartPercent = 0,
|
||||
topEndPercent = cornerRadius,
|
||||
bottomStartPercent = 0,
|
||||
bottomEndPercent = cornerRadius
|
||||
)
|
||||
/**
|
||||
* middle button
|
||||
*/
|
||||
else -> RoundedCornerShape(
|
||||
topStartPercent = 0,
|
||||
topEndPercent = 0,
|
||||
bottomStartPercent = 0,
|
||||
bottomEndPercent = 0
|
||||
)
|
||||
},
|
||||
border = BorderStroke(
|
||||
1.dp, if (selectedIndex.value == index) {
|
||||
MaterialTheme.colors.secondary.copy(alpha = 0.75f)
|
||||
} else {
|
||||
MaterialTheme.colors.secondary
|
||||
}
|
||||
),
|
||||
colors = if (selectedIndex.value == index) {
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
backgroundColor = MaterialTheme.colors.secondary
|
||||
)
|
||||
} else {
|
||||
ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.background)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = item,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = if (selectedIndex.value == index) {
|
||||
MaterialTheme.colors.onSecondary
|
||||
} else {
|
||||
MaterialTheme.colors.onBackground
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +1,102 @@
|
|||
package app.omnivore.omnivore.ui.library
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
|
||||
import app.omnivore.omnivore.ui.components.LabelChipColors
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LibraryFilterBar(viewModel: LibraryViewModel) {
|
||||
var isSavedItemFilterMenuExpanded by remember { mutableStateOf(false) }
|
||||
val activeSavedItemFilter: SavedItemFilter by viewModel.appliedFilterLiveData.observeAsState(SavedItemFilter.INBOX)
|
||||
val activeLabels: List<SavedItemLabel> by viewModel.activeLabelsLiveData.observeAsState(listOf())
|
||||
|
||||
var isSavedItemSortFilterMenuExpanded by remember { mutableStateOf(false) }
|
||||
val activeSavedItemSortFilter: SavedItemSortFilter by viewModel.appliedSortFilterLiveData.observeAsState(SavedItemSortFilter.NEWEST)
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
Column {
|
||||
Row(
|
||||
LazyRow(
|
||||
state = listState,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
AssistChip(
|
||||
onClick = { isSavedItemFilterMenuExpanded = true },
|
||||
label = { Text(activeSavedItemFilter.displayText) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = "drop down button to change primary library filter"
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
AssistChip(
|
||||
onClick = { isSavedItemSortFilterMenuExpanded = true },
|
||||
label = { Text(activeSavedItemSortFilter.displayText) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = "drop down button to change library sort order"
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
item {
|
||||
AssistChip(
|
||||
onClick = { isSavedItemFilterMenuExpanded = true },
|
||||
label = { Text(activeSavedItemFilter.displayText) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = "drop down button to change primary library filter"
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
AssistChip(
|
||||
onClick = { isSavedItemSortFilterMenuExpanded = true },
|
||||
label = { Text(activeSavedItemSortFilter.displayText) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = "drop down button to change library sort order"
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
AssistChip(
|
||||
onClick = { viewModel.showLabelsSelectionSheetLiveData.value = true },
|
||||
label = { Text("Labels") },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = "drop down button to open label selection sheet"
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
}
|
||||
items(activeLabels.sortedBy { it.name }) { label ->
|
||||
val chipColors = LabelChipColors.fromHex(label.color)
|
||||
|
||||
AssistChip(
|
||||
onClick = {
|
||||
viewModel.updateAppliedLabels(
|
||||
(viewModel.activeLabelsLiveData.value ?: listOf()).filter { it.savedItemLabelId != label.savedItemLabelId }
|
||||
)
|
||||
},
|
||||
label = { Text(label.name) },
|
||||
border = null,
|
||||
colors = SuggestionChipDefaults.elevatedSuggestionChipColors(
|
||||
containerColor = chipColors.containerColor,
|
||||
labelColor = chipColors.textColor,
|
||||
iconContentColor = chipColors.textColor
|
||||
),
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "close icon to remove label"
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SavedItemFilterContextMenu(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package app.omnivore.omnivore.ui.library
|
||||
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
|
|
@ -21,6 +22,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.navigation.NavHostController
|
||||
import app.omnivore.omnivore.Routes
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemCardDataWithLabels
|
||||
import app.omnivore.omnivore.ui.components.LabelsSelectionSheet
|
||||
import app.omnivore.omnivore.ui.savedItemViews.SavedItemCard
|
||||
import app.omnivore.omnivore.ui.reader.PDFReaderActivity
|
||||
import app.omnivore.omnivore.ui.reader.WebReaderLoadingContainerActivity
|
||||
|
|
@ -65,7 +67,6 @@ fun LibraryViewContent(libraryViewModel: LibraryViewModel, modifier: Modifier) {
|
|||
|
||||
val cardsData: List<SavedItemCardDataWithLabels> by libraryViewModel.itemsLiveData.observeAsState(listOf())
|
||||
val searchedCardsData: List<SavedItemCardDataWithLabels> by libraryViewModel.searchItemsLiveData.observeAsState(listOf())
|
||||
val searchText: String by libraryViewModel.searchTextLiveData.observeAsState("")
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -89,6 +90,7 @@ fun LibraryViewContent(libraryViewModel: LibraryViewModel, modifier: Modifier) {
|
|||
items(if (libraryViewModel.showSearchField) searchedCardsData else cardsData) { cardDataWithLabels ->
|
||||
SavedItemCard(
|
||||
cardData = cardDataWithLabels.cardData,
|
||||
labels = cardDataWithLabels.labels,
|
||||
onClickHandler = {
|
||||
val activityClass = if (cardDataWithLabels.cardData.isPDF()) PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java
|
||||
val intent = Intent(context, activityClass)
|
||||
|
|
@ -101,7 +103,13 @@ fun LibraryViewContent(libraryViewModel: LibraryViewModel, modifier: Modifier) {
|
|||
}
|
||||
|
||||
InfiniteListHandler(listState = listState) {
|
||||
libraryViewModel.load()
|
||||
if (cardsData.isEmpty()) {
|
||||
Log.d("sync", "loading with load func")
|
||||
libraryViewModel.initialLoad()
|
||||
} else {
|
||||
Log.d("sync", "loading with search api")
|
||||
libraryViewModel.loadUsingSearchAPI()
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(
|
||||
|
|
@ -109,6 +117,8 @@ fun LibraryViewContent(libraryViewModel: LibraryViewModel, modifier: Modifier) {
|
|||
state = pullRefreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
)
|
||||
|
||||
LabelsSelectionSheet(viewModel = libraryViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,21 +3,20 @@ package app.omnivore.omnivore.ui.library
|
|||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.omnivore.omnivore.*
|
||||
import app.omnivore.omnivore.dataService.*
|
||||
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
|
||||
import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput
|
||||
import app.omnivore.omnivore.networking.*
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItem
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemCardDataWithLabels
|
||||
import app.omnivore.omnivore.ui.reader.WebFont
|
||||
import app.omnivore.omnivore.persistence.entities.*
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -27,7 +26,10 @@ class LibraryViewModel @Inject constructor(
|
|||
private val dataService: DataService,
|
||||
private val datastoreRepo: DatastoreRepository
|
||||
): ViewModel() {
|
||||
private val contentRequestChannel = Channel<String>(capacity = Channel.UNLIMITED)
|
||||
|
||||
private var cursor: String? = null
|
||||
private var librarySearchCursor: String? = null
|
||||
|
||||
// These are used to make sure we handle search result
|
||||
// responses in the right order
|
||||
|
|
@ -41,6 +43,10 @@ class LibraryViewModel @Inject constructor(
|
|||
val itemsLiveData = MediatorLiveData<List<SavedItemCardDataWithLabels>>()
|
||||
val appliedFilterLiveData = MutableLiveData(SavedItemFilter.INBOX)
|
||||
val appliedSortFilterLiveData = MutableLiveData(SavedItemSortFilter.NEWEST)
|
||||
val showLabelsSelectionSheetLiveData = MutableLiveData(false)
|
||||
val labelsSelectionCurrentItemLiveData = MutableLiveData<String?>(null)
|
||||
val savedItemLabelsLiveData = dataService.db.savedItemLabelDao().getSavedItemLabelsLiveData()
|
||||
val activeLabelsLiveData = MutableLiveData<List<SavedItemLabel>>(listOf())
|
||||
|
||||
var isRefreshing by mutableStateOf(false)
|
||||
var showSearchField by mutableStateOf(false)
|
||||
|
|
@ -50,6 +56,12 @@ class LibraryViewModel @Inject constructor(
|
|||
if (hasLoadedInitialFilters) { return }
|
||||
hasLoadedInitialFilters = false
|
||||
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
dataService.syncLabels()
|
||||
}
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
datastoreRepo.getString(DatastoreKeys.lastUsedSavedItemFilter)?.let { str ->
|
||||
try {
|
||||
|
|
@ -72,6 +84,11 @@ class LibraryViewModel @Inject constructor(
|
|||
|
||||
viewModelScope.launch {
|
||||
handleFilterChanges()
|
||||
for (slug in contentRequestChannel) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
dataService.fetchSavedItemContent(slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +117,19 @@ class LibraryViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun initialLoad() {
|
||||
if (getLastSyncTime() == null) {
|
||||
hasLoadedInitialFilters = false
|
||||
cursor = null
|
||||
librarySearchCursor = null
|
||||
searchIdx = 0
|
||||
receivedIdx = 0
|
||||
}
|
||||
|
||||
if (hasLoadedInitialFilters) { return }
|
||||
load()
|
||||
}
|
||||
|
||||
fun load(clearPreviousSearch: Boolean = false) {
|
||||
loadInitialFilterValues()
|
||||
|
||||
|
|
@ -108,6 +138,30 @@ class LibraryViewModel @Inject constructor(
|
|||
performSearch(clearPreviousSearch)
|
||||
} else {
|
||||
syncItems()
|
||||
loadUsingSearchAPI()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadUsingSearchAPI() {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val result = dataService.librarySearch(cursor = librarySearchCursor, query = searchQueryString())
|
||||
result.cursor?.let {
|
||||
librarySearchCursor = it
|
||||
}
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
isRefreshing = false
|
||||
}
|
||||
|
||||
result.savedItemSlugs.map {
|
||||
val isSavedInDB = dataService.isSavedItemContentStoredInDB(it)
|
||||
|
||||
if (!isSavedInDB) {
|
||||
delay(2000)
|
||||
contentRequestChannel.send(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -128,11 +182,18 @@ class LibraryViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun updateAppliedLabels(labels: List<SavedItemLabel>) {
|
||||
viewModelScope.launch {
|
||||
activeLabelsLiveData.value = labels
|
||||
handleFilterChanges()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleFilterChanges() {
|
||||
if (searchTextLiveData.value != "") {
|
||||
performSearch(true)
|
||||
} else if (appliedSortFilterLiveData.value != null && appliedFilterLiveData.value != null) {
|
||||
itemsLiveDataInternal = dataService.libraryLiveData(appliedFilterLiveData.value!!, appliedSortFilterLiveData.value!!, listOf())
|
||||
itemsLiveDataInternal = dataService.libraryLiveData(appliedFilterLiveData.value!!, appliedSortFilterLiveData.value!!, activeLabelsLiveData.value ?: listOf())
|
||||
itemsLiveData.removeSource(itemsLiveDataInternal)
|
||||
itemsLiveData.addSource(itemsLiveDataInternal, itemsLiveData::setValue)
|
||||
}
|
||||
|
|
@ -157,14 +218,13 @@ class LibraryViewModel @Inject constructor(
|
|||
// Fetch content for the initial batch only
|
||||
if (isInitialBatch) {
|
||||
for (slug in result.savedItemSlugs) {
|
||||
dataService.syncSavedItemContent(slug)
|
||||
delay(250)
|
||||
contentRequestChannel.send(slug)
|
||||
}
|
||||
}
|
||||
|
||||
val totalCount = count + result.count
|
||||
|
||||
Log.d("sync", "fetched ${result.count} items")
|
||||
|
||||
if (!result.hasError && result.hasMoreItems && result.cursor != null) {
|
||||
performItemSync(
|
||||
cursor = result.cursor,
|
||||
|
|
@ -187,7 +247,7 @@ class LibraryViewModel @Inject constructor(
|
|||
searchIdx += 1
|
||||
|
||||
// Execute the search
|
||||
val searchResult = networker.typeaheadSearch(searchQueryString())
|
||||
val searchResult = networker.typeaheadSearch(searchTextLiveData.value ?: "")
|
||||
|
||||
// Search results aren't guaranteed to return in order so this
|
||||
// will discard old results that are returned while a user is typing.
|
||||
|
|
@ -226,25 +286,90 @@ class LibraryViewModel @Inject constructor(
|
|||
dataService.unarchiveSavedItem(itemID)
|
||||
}
|
||||
}
|
||||
SavedItemAction.EditLabels -> {
|
||||
labelsSelectionCurrentItemLiveData.value = itemID
|
||||
showLabelsSelectionSheetLiveData.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSavedItemLabels(savedItemID: String, labels: List<SavedItemLabel>) {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val input = SetLabelsInput(labelIds = labels.map { it.savedItemLabelId }, pageId = savedItemID)
|
||||
val networkResult = networker.updateLabelsForSavedItem(input)
|
||||
|
||||
// TODO: assign a server sync status to these
|
||||
val crossRefs = labels.map {
|
||||
SavedItemAndSavedItemLabelCrossRef(
|
||||
savedItemLabelId = it.savedItemLabelId,
|
||||
savedItemId = savedItemID
|
||||
)
|
||||
}
|
||||
|
||||
// Remove all labels first
|
||||
dataService.db.savedItemAndSavedItemLabelCrossRefDao().deleteRefsBySavedItemId(savedItemID)
|
||||
|
||||
// Add back the current labels
|
||||
dataService.db.savedItemAndSavedItemLabelCrossRefDao().insertAll(crossRefs)
|
||||
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
handleFilterChanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createNewSavedItemLabel(labelName: String, hexColorValue: String) {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val newLabel = networker.createNewLabel(CreateLabelInput(color = 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun currentSavedItemUnderEdit(): SavedItemCardDataWithLabels? {
|
||||
labelsSelectionCurrentItemLiveData.value?.let { itemID ->
|
||||
return itemsLiveData.value?.first { it.cardData.savedItemId == itemID }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun searchQueryString(): String {
|
||||
return searchTextLiveData.value ?: ""
|
||||
// Unused code for typeahead search
|
||||
// var query = "${appliedFilterLiveData.value?.queryString} ${appliedSortFilterLiveData.value?.queryString}"
|
||||
// val searchText = searchTextLiveData.value ?: ""
|
||||
//
|
||||
// if (searchText.isNotEmpty()) {
|
||||
// query += " $searchText"
|
||||
// }
|
||||
//
|
||||
// return query
|
||||
var query = "${appliedFilterLiveData.value?.queryString} ${appliedSortFilterLiveData.value?.queryString}"
|
||||
val searchText = searchTextLiveData.value ?: ""
|
||||
|
||||
if (searchText.isNotEmpty()) {
|
||||
query += " $searchText"
|
||||
}
|
||||
|
||||
activeLabelsLiveData.value?.let {
|
||||
if (it.isNotEmpty()) {
|
||||
query += " label:"
|
||||
query += it.joinToString { label -> label.name }
|
||||
}
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
}
|
||||
|
||||
enum class SavedItemAction {
|
||||
Delete,
|
||||
Archive,
|
||||
Unarchive
|
||||
Unarchive,
|
||||
EditLabels
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,10 @@ import androidx.compose.ui.focus.FocusRequester
|
|||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.omnivore.omnivore.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -33,7 +35,7 @@ fun SearchBar(
|
|||
) {
|
||||
val searchText: String by libraryViewModel.searchTextLiveData.observeAsState("")
|
||||
|
||||
SmallTopAppBar(
|
||||
TopAppBar(
|
||||
title = {
|
||||
if (libraryViewModel.showSearchField) {
|
||||
SearchField(searchText) { libraryViewModel.updateSearchText(it) }
|
||||
|
|
@ -41,7 +43,7 @@ fun SearchBar(
|
|||
Text("Library")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.smallTopAppBarColors(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
actions = {
|
||||
|
|
@ -65,7 +67,7 @@ fun SearchBar(
|
|||
|
||||
IconButton(onClick = onSettingsIconClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings,
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import android.util.Log
|
|||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
|
|
@ -14,8 +13,10 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -26,6 +27,7 @@ import androidx.compose.ui.res.painterResource
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import app.omnivore.omnivore.R
|
||||
import app.omnivore.omnivore.ui.components.SegmentedControl
|
||||
|
||||
@Composable
|
||||
fun WebPreferencesDialog(onDismiss: () -> Unit, webReaderViewModel: WebReaderViewModel) {
|
||||
|
|
@ -34,7 +36,7 @@ fun WebPreferencesDialog(onDismiss: () -> Unit, webReaderViewModel: WebReaderVie
|
|||
shape = RoundedCornerShape(16.dp),
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.height(300.dp)
|
||||
.height(350.dp)
|
||||
) {
|
||||
WebPreferencesView(webReaderViewModel)
|
||||
}
|
||||
|
|
@ -43,7 +45,8 @@ fun WebPreferencesDialog(onDismiss: () -> Unit, webReaderViewModel: WebReaderVie
|
|||
|
||||
@Composable
|
||||
fun WebPreferencesView(webReaderViewModel: WebReaderViewModel) {
|
||||
val currentWebPreferences = webReaderViewModel.storedWebPreferences(isSystemInDarkTheme())
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val currentWebPreferences = webReaderViewModel.storedWebPreferences(isDark)
|
||||
val isFontListExpanded = remember { mutableStateOf(false) }
|
||||
val highContrastTextSwitchState = remember { mutableStateOf(currentWebPreferences.prefersHighContrastText) }
|
||||
val selectedWebFontRawValue = remember { mutableStateOf(currentWebPreferences.fontFamily.rawValue) }
|
||||
|
|
@ -95,6 +98,21 @@ fun WebPreferencesView(webReaderViewModel: WebReaderViewModel) {
|
|||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
Text("Theme:")
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
SegmentedControl(
|
||||
items = webReaderViewModel.systemThemeKeys,
|
||||
initialSelectedItemIndex = webReaderViewModel.systemThemeKeys.indexOf(currentWebPreferences.storedThemePreference)
|
||||
) {
|
||||
webReaderViewModel.updateStoredThemePreference(it, isDark)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
|
|
@ -180,6 +198,7 @@ data class WebPreferences(
|
|||
val lineHeight: Int,
|
||||
val maxWidthPercentage: Int,
|
||||
val themeKey: String,
|
||||
val storedThemePreference: String,
|
||||
val fontFamily: WebFont,
|
||||
val prefersHighContrastText: Boolean
|
||||
)
|
||||
|
|
|
|||
|
|
@ -47,13 +47,15 @@ fun WebReader(
|
|||
)
|
||||
|
||||
val styledContent = webReaderContent.styledContent()
|
||||
val isInDarkMode = isSystemInDarkTheme()
|
||||
val isInDarkMode = preferences.themeKey == "Dark"
|
||||
|
||||
Box {
|
||||
AndroidView(factory = {
|
||||
OmnivoreWebView(it).apply {
|
||||
if (isInDarkMode) {
|
||||
setBackgroundColor(Color.Transparent.hashCode())
|
||||
} else {
|
||||
setBackgroundColor(Color.White.hashCode())
|
||||
}
|
||||
viewModel = webReaderViewModel
|
||||
|
||||
|
|
@ -127,6 +129,14 @@ fun WebReader(
|
|||
for (script in webReaderViewModel.javascriptDispatchQueue) {
|
||||
Log.d("js", "executing script: $script")
|
||||
it.evaluateJavascript(script, null)
|
||||
|
||||
if (script.contains("event.isDark")) {
|
||||
if (script.contains("event.isDark = 'true'")) {
|
||||
it.setBackgroundColor(Color.Transparent.hashCode())
|
||||
} else {
|
||||
it.setBackgroundColor(Color.White.hashCode())
|
||||
}
|
||||
}
|
||||
}
|
||||
webReaderViewModel.resetJavascriptDispatchQueue()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ data class WebReaderContent(
|
|||
val publishedAt =
|
||||
"new Date().toISOString()" //if (item.publishDate != null) "new Date((item.publishDate!.timeIntervalSince1970 * 1000)).toISOString()" else "undefined"
|
||||
val textFontSize = preferences.textFontSize
|
||||
val highlightCssFilePath = "highlight${if (preferences.themeKey == "Gray") "-dark" else ""}.css"
|
||||
val highlightCssFilePath = "highlight${if (preferences.themeKey == "Dark") "-dark" else ""}.css"
|
||||
|
||||
Log.d("theme", "current theme is: ${preferences.themeKey}")
|
||||
|
||||
|
|
|
|||
|
|
@ -26,11 +26,15 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import app.omnivore.omnivore.MainActivity
|
||||
import app.omnivore.omnivore.R
|
||||
import app.omnivore.omnivore.ui.components.WebReaderLabelsSelectionSheet
|
||||
import app.omnivore.omnivore.ui.savedItemViews.SavedItemContextMenu
|
||||
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
|
|
@ -142,15 +146,15 @@ fun WebReaderLoadingContainer(slug: String? = null, requestID: String? = null, o
|
|||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { isMenuExpanded = true }) {
|
||||
IconButton(onClick = { showWebPreferencesDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Menu,
|
||||
painter = painterResource(id = R.drawable.format_letter_case),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showWebPreferencesDialog = true }) {
|
||||
IconButton(onClick = { isMenuExpanded = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings, // TODO: set a better icon
|
||||
painter = painterResource(id = R.drawable.dots_horizontal),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
|
|
@ -188,6 +192,8 @@ fun WebReaderLoadingContainer(slug: String? = null, requestID: String? = null, o
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
WebReaderLabelsSelectionSheet(webReaderViewModel)
|
||||
}
|
||||
|
||||
LaunchedEffect(shouldPopView) {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,12 @@ import androidx.lifecycle.viewModelScope
|
|||
import app.omnivore.omnivore.DatastoreKeys
|
||||
import app.omnivore.omnivore.DatastoreRepository
|
||||
import app.omnivore.omnivore.dataService.*
|
||||
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
|
||||
import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItem
|
||||
import app.omnivore.omnivore.networking.*
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemAndSavedItemLabelCrossRef
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
|
||||
import app.omnivore.omnivore.ui.library.SavedItemAction
|
||||
import com.google.gson.Gson
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
|
@ -23,7 +27,8 @@ import javax.inject.Inject
|
|||
|
||||
data class WebReaderParams(
|
||||
val item: SavedItem,
|
||||
val articleContent: ArticleContent
|
||||
val articleContent: ArticleContent,
|
||||
val labels: List<SavedItemLabel>
|
||||
)
|
||||
|
||||
data class AnnotationWebViewMessage(
|
||||
|
|
@ -46,12 +51,18 @@ class WebReaderViewModel @Inject constructor(
|
|||
val shouldPopViewLiveData = MutableLiveData(false)
|
||||
val hasFetchError = MutableLiveData(false)
|
||||
val currentToolbarHeightLiveData = MutableLiveData(0.0f)
|
||||
val showLabelsSelectionSheetLiveData = MutableLiveData(false)
|
||||
val savedItemLabelsLiveData = dataService.db.savedItemLabelDao().getSavedItemLabelsLiveData()
|
||||
|
||||
val systemThemeKeys = listOf("Light", "Dark", "System")
|
||||
|
||||
var hasTappedExistingHighlight = false
|
||||
var lastTapCoordinates: TapCoordinates? = null
|
||||
private var isLoading = false
|
||||
private var slug: String? = null
|
||||
|
||||
fun loadItem(slug: String?, requestID: String?) {
|
||||
this.slug = slug
|
||||
if (isLoading || webReaderParamsLiveData.value != null) { return }
|
||||
isLoading = true
|
||||
Log.d("reader", "load item called")
|
||||
|
|
@ -72,14 +83,14 @@ class WebReaderViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private suspend fun loadItemUsingSlug(slug: String) {
|
||||
loadItemFromDB(slug)
|
||||
|
||||
val webReaderParams = loadItemFromServer(slug)
|
||||
|
||||
if (webReaderParams != null) {
|
||||
Log.d("reader", "data loaded from server")
|
||||
webReaderParamsLiveData.postValue(webReaderParams)
|
||||
isLoading = false
|
||||
} else {
|
||||
loadItemFromDB(slug)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +99,7 @@ class WebReaderViewModel @Inject constructor(
|
|||
val isSuccessful = webReaderParams?.articleContent?.contentStatus == "SUCCEEDED"
|
||||
|
||||
if (webReaderParams != null && isSuccessful) {
|
||||
this.slug = webReaderParams.item.slug
|
||||
webReaderParamsLiveData.postValue(webReaderParams)
|
||||
isLoading = false
|
||||
} else if (requestCount < 7) {
|
||||
|
|
@ -114,7 +126,13 @@ class WebReaderViewModel @Inject constructor(
|
|||
)
|
||||
|
||||
Log.d("sync", "data loaded from db")
|
||||
webReaderParamsLiveData.postValue(WebReaderParams(persistedItem.savedItem, articleContent))
|
||||
webReaderParamsLiveData.postValue(
|
||||
WebReaderParams(
|
||||
persistedItem.savedItem,
|
||||
articleContent,
|
||||
persistedItem.labels
|
||||
)
|
||||
)
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
|
@ -134,7 +152,7 @@ class WebReaderViewModel @Inject constructor(
|
|||
labelsJSONString = Gson().toJson(articleQueryResult.labels)
|
||||
)
|
||||
|
||||
return WebReaderParams(article, articleContent)
|
||||
return WebReaderParams(article, articleContent, articleQueryResult.labels)
|
||||
}
|
||||
|
||||
fun handleSavedItemAction(itemID: String, action: SavedItemAction) {
|
||||
|
|
@ -157,6 +175,9 @@ class WebReaderViewModel @Inject constructor(
|
|||
popToLibraryView()
|
||||
}
|
||||
}
|
||||
SavedItemAction.EditLabels -> {
|
||||
showLabelsSelectionSheetLiveData.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -238,6 +259,7 @@ class WebReaderViewModel @Inject constructor(
|
|||
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().first { it.rawValue == storedFontFamily }
|
||||
|
||||
val prefersHighContrastFont = datastoreRepo.getString(DatastoreKeys.prefersWebHighContrastText) == "true"
|
||||
|
|
@ -246,12 +268,33 @@ class WebReaderViewModel @Inject constructor(
|
|||
textFontSize = storedFontSize ?: 12,
|
||||
lineHeight = storedLineHeight ?: 150,
|
||||
maxWidthPercentage = storedMaxWidth ?: 100,
|
||||
themeKey = if (isDarkMode) "Gray" else "LightGray",
|
||||
themeKey = themeKey(isDarkMode, storedThemePreference),
|
||||
storedThemePreference = storedThemePreference,
|
||||
fontFamily = storedWebFont,
|
||||
prefersHighContrastText = prefersHighContrastFont
|
||||
)
|
||||
}
|
||||
|
||||
fun themeKey(isDarkMode: Boolean, storedThemePreference: String): String {
|
||||
if (storedThemePreference == "System") {
|
||||
return if (isDarkMode) "Dark" else "Light"
|
||||
}
|
||||
|
||||
return storedThemePreference
|
||||
}
|
||||
|
||||
fun updateStoredThemePreference(index: Int, isDarkMode: Boolean) {
|
||||
val newThemeKey = themeKey(isDarkMode, systemThemeKeys[index])
|
||||
|
||||
runBlocking {
|
||||
datastoreRepo.putString(DatastoreKeys.preferredTheme, systemThemeKeys[index])
|
||||
}
|
||||
|
||||
val isDark = newThemeKey == "Dark"
|
||||
val script = "var event = new Event('updateColorMode');event.isDark = '$isDark';document.dispatchEvent(event);"
|
||||
enqueueScript(script)
|
||||
}
|
||||
|
||||
fun updateFontSize(isIncrease: Boolean) {
|
||||
val delta = if (isIncrease) 2 else -2
|
||||
var newFontSize: Int
|
||||
|
|
@ -314,4 +357,57 @@ class WebReaderViewModel @Inject constructor(
|
|||
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) {
|
||||
val input = SetLabelsInput(labelIds = labels.map { it.savedItemLabelId }, pageId = savedItemID)
|
||||
val networkResult = networker.updateLabelsForSavedItem(input)
|
||||
|
||||
// TODO: assign a server sync status to these
|
||||
val crossRefs = labels.map {
|
||||
SavedItemAndSavedItemLabelCrossRef(
|
||||
savedItemLabelId = it.savedItemLabelId,
|
||||
savedItemId = savedItemID
|
||||
)
|
||||
}
|
||||
|
||||
// Remove all labels first
|
||||
dataService.db.savedItemAndSavedItemLabelCrossRefDao().deleteRefsBySavedItemId(savedItemID)
|
||||
|
||||
// Add back the current labels
|
||||
dataService.db.savedItemAndSavedItemLabelCrossRefDao().insertAll(crossRefs)
|
||||
|
||||
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 createNewSavedItemLabel(labelName: String, hexColorValue: String) {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val newLabel = networker.createNewLabel(CreateLabelInput(color = 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,12 +18,15 @@ import app.omnivore.omnivore.ui.auth.LoginViewModel
|
|||
import app.omnivore.omnivore.ui.auth.WelcomeScreen
|
||||
import app.omnivore.omnivore.ui.library.LibraryView
|
||||
import app.omnivore.omnivore.ui.library.LibraryViewModel
|
||||
import app.omnivore.omnivore.ui.settings.PolicyWebView
|
||||
import app.omnivore.omnivore.ui.settings.SettingsViewModel
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
|
||||
@Composable
|
||||
fun RootView(
|
||||
loginViewModel: LoginViewModel,
|
||||
libraryViewModel: LibraryViewModel
|
||||
libraryViewModel: LibraryViewModel,
|
||||
settingsViewModel: SettingsViewModel
|
||||
) {
|
||||
val hasAuthToken: Boolean by loginViewModel.hasAuthTokenLiveData.observeAsState(false)
|
||||
val systemUiController = rememberSystemUiController()
|
||||
|
|
@ -45,7 +48,8 @@ fun RootView(
|
|||
if (hasAuthToken) {
|
||||
PrimaryNavigator(
|
||||
loginViewModel = loginViewModel,
|
||||
libraryViewModel = libraryViewModel
|
||||
libraryViewModel = libraryViewModel,
|
||||
settingsViewModel = settingsViewModel
|
||||
)
|
||||
} else {
|
||||
WelcomeScreen(viewModel = loginViewModel)
|
||||
|
|
@ -63,7 +67,8 @@ fun RootView(
|
|||
@Composable
|
||||
fun PrimaryNavigator(
|
||||
loginViewModel: LoginViewModel,
|
||||
libraryViewModel: LibraryViewModel
|
||||
libraryViewModel: LibraryViewModel,
|
||||
settingsViewModel: SettingsViewModel
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
|
|
@ -76,7 +81,19 @@ fun PrimaryNavigator(
|
|||
}
|
||||
|
||||
composable(Routes.Settings.route) {
|
||||
SettingsView(loginViewModel = loginViewModel, navController = navController)
|
||||
SettingsView(loginViewModel = loginViewModel, settingsViewModel = settingsViewModel, navController = navController)
|
||||
}
|
||||
|
||||
composable(Routes.Documentation.route) {
|
||||
PolicyWebView(navController = navController, url = "https://docs.omnivore.app")
|
||||
}
|
||||
|
||||
composable(Routes.PrivacyPolicy.route) {
|
||||
PolicyWebView(navController = navController, url = "https://omnivore.app/app/privacy")
|
||||
}
|
||||
|
||||
composable(Routes.TermsAndConditions.route) {
|
||||
PolicyWebView(navController = navController, url = "https://omnivore.app/app/terms")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@ package app.omnivore.omnivore.ui.savedItemViews
|
|||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.SuggestionChipDefaults.elevatedSuggestionChipColors
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -12,28 +16,32 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItem
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemCardData
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
|
||||
import app.omnivore.omnivore.ui.components.LabelChipColors
|
||||
import app.omnivore.omnivore.ui.library.SavedItemAction
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SavedItemCard(cardData: SavedItemCardData, onClickHandler: () -> Unit, actionHandler: (SavedItemAction) -> Unit) {
|
||||
fun SavedItemCard(cardData: SavedItemCardData, labels: List<SavedItemLabel>, onClickHandler: () -> Unit, actionHandler: (SavedItemAction) -> Unit) {
|
||||
var isMenuExpanded by remember { mutableStateOf(false) }
|
||||
val publisherDisplayName = cardData.publisherDisplayName()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
Column {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.combinedClickable(
|
||||
onClick = onClickHandler,
|
||||
onLongClick = { isMenuExpanded = true }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
.combinedClickable(
|
||||
onClick = onClickHandler,
|
||||
onLongClick = { isMenuExpanded = true }
|
||||
)
|
||||
.background(if (isMenuExpanded) Color.LightGray else Color.Transparent)
|
||||
) {
|
||||
Column(
|
||||
|
|
@ -79,6 +87,31 @@ fun SavedItemCard(cardData: SavedItemCardData, onClickHandler: () -> Unit, actio
|
|||
}
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
state = listState,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
) {
|
||||
items(labels.sortedBy { it.name }) { label ->
|
||||
val chipColors = LabelChipColors.fromHex(label.color)
|
||||
|
||||
SuggestionChip(
|
||||
onClick = onClickHandler,
|
||||
label = { Text(label.name) },
|
||||
border = null,
|
||||
colors = elevatedSuggestionChipColors(
|
||||
containerColor = chipColors.containerColor,
|
||||
labelColor = chipColors.textColor,
|
||||
iconContentColor = chipColors.textColor
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
|
||||
|
||||
SavedItemContextMenu(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package app.omnivore.omnivore.ui.savedItemViews
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CheckCircle
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.List
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
|
|
@ -8,6 +9,8 @@ import androidx.compose.material3.DropdownMenuItem
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import app.omnivore.omnivore.R
|
||||
import app.omnivore.omnivore.ui.library.SavedItemAction
|
||||
|
||||
@Composable
|
||||
|
|
@ -21,6 +24,19 @@ fun SavedItemContextMenu(
|
|||
expanded = isExpanded,
|
||||
onDismissRequest = onDismiss
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Edit Labels") },
|
||||
onClick = {
|
||||
actionHandler(SavedItemAction.EditLabels)
|
||||
onDismiss()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.tag),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isArchived) "Unarchive" else "Archive") },
|
||||
onClick = {
|
||||
|
|
@ -30,7 +46,7 @@ fun SavedItemContextMenu(
|
|||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Outlined.List, // TODO: use more appropriate icon
|
||||
painter = painterResource(id = R.drawable.archive_outline),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
package app.omnivore.omnivore.ui.settings
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignIn
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
|
||||
|
||||
@Composable
|
||||
fun LogoutDialog(onClose: (Boolean) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { onClose(false) },
|
||||
title = { Text(text = "Logout") },
|
||||
text = {
|
||||
Text("Are you sure you want to logout?")
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
// Sign out google users
|
||||
val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
|
||||
.build()
|
||||
|
||||
val googleSignIn = GoogleSignIn.getClient(context, signInOptions)
|
||||
googleSignIn.signOut()
|
||||
onClose(true)
|
||||
}) {
|
||||
Text("Confirm")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = { onClose(false) }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package app.omnivore.omnivore.ui.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@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("Manage Account")
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = { settingsViewModel.resetDataCache() })
|
||||
) {
|
||||
Text("Reset Data Cache")
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
Icon(imageVector = Icons.Filled.Refresh, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package app.omnivore.omnivore.ui.settings
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.navigation.NavHostController
|
||||
import app.omnivore.omnivore.Routes
|
||||
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "SetJavaScriptEnabled")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PolicyWebView(navController: NavHostController, url: String) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { androidx.compose.material3.Text("Settings") },
|
||||
actions = {
|
||||
IconButton(onClick = { navController.navigate(Routes.Settings.route) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}, colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
val isDarkMode = isSystemInDarkTheme()
|
||||
|
||||
Box {
|
||||
AndroidView(factory = {
|
||||
WebView(it).apply {
|
||||
if (isDarkMode) {
|
||||
setBackgroundColor(Color.Transparent.hashCode())
|
||||
}
|
||||
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
settings.javaScriptEnabled = true
|
||||
settings.allowContentAccess = true
|
||||
settings.allowFileAccess = true
|
||||
settings.domStorageEnabled = true
|
||||
|
||||
alpha = 0.0f
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
view?.animate()?.alpha(1.0f)?.duration = 200
|
||||
}
|
||||
}
|
||||
|
||||
// val themeID = if (isDarkMode) "Gray" else "LightGray"
|
||||
// loadUrl(url, mutableMapOf("Set-Cookie" to "theme=$themeID; Max-Age=31536000;"))
|
||||
loadUrl(url, mutableMapOf())
|
||||
}
|
||||
}, update = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +1,43 @@
|
|||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.Routes
|
||||
import app.omnivore.omnivore.ui.auth.LoginViewModel
|
||||
import app.omnivore.omnivore.ui.settings.LogoutDialog
|
||||
import app.omnivore.omnivore.ui.settings.ManageAccountDialog
|
||||
import app.omnivore.omnivore.ui.settings.SettingsViewModel
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignIn
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
|
||||
import io.intercom.android.sdk.Intercom
|
||||
import io.intercom.android.sdk.IntercomSpace
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
@Composable
|
||||
fun SettingsView(
|
||||
loginViewModel: LoginViewModel,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SmallTopAppBar(
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
colors = TopAppBarDefaults.smallTopAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
actions = {
|
||||
IconButton(onClick = { navController.navigate(Routes.Library.route) }) {
|
||||
Icon(
|
||||
|
|
@ -37,42 +45,132 @@ fun SettingsView(
|
|||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}, colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
SettingsViewContent(
|
||||
loginViewModel = loginViewModel,
|
||||
settingsViewModel = settingsViewModel,
|
||||
navController = navController,
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
top = paddingValues.calculateTopPadding(),
|
||||
bottom = paddingValues.calculateBottomPadding()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsViewContent(loginViewModel: LoginViewModel, settingsViewModel: SettingsViewModel, navController: NavHostController, modifier: Modifier) {
|
||||
val showLogoutDialog = remember { mutableStateOf(false) }
|
||||
val showManageAccountDialog = remember { mutableStateOf(false ) }
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(horizontal = 6.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
LogoutButton { loginViewModel.logout() }
|
||||
Button(onClick = {
|
||||
Intercom.client().present(space = IntercomSpace.Messages)
|
||||
}) {
|
||||
Text(text = "Open Help Center")
|
||||
|
||||
// profile pic and name
|
||||
|
||||
// SettingRow(text = "Labels") { Log.d("settings", "labels button tapped") }
|
||||
// RowDivider()
|
||||
// SettingRow(text = "Emails") { Log.d("settings", "emails button tapped") }
|
||||
// RowDivider()
|
||||
// SettingRow(text = "Subscriptions") { Log.d("settings", "subscriptions button tapped") }
|
||||
// RowDivider()
|
||||
// SettingRow(text = "Clubs") { Log.d("settings", "clubs button tapped") }
|
||||
|
||||
// SectionSpacer()
|
||||
|
||||
// SettingRow(text = "Push Notifications") { Log.d("settings", "pn button tapped") }
|
||||
// RowDivider()
|
||||
// SettingRow(text = "Text to Speech") { Log.d("settings", "tts button tapped") }
|
||||
//
|
||||
// SectionSpacer()
|
||||
|
||||
SettingRow(text = "Documentation") { navController.navigate(Routes.Documentation.route) }
|
||||
RowDivider()
|
||||
SettingRow(text = "Feedback") { Intercom.client().present(space = IntercomSpace.Messages) }
|
||||
RowDivider()
|
||||
SettingRow(text = "Privacy Policy") { navController.navigate(Routes.PrivacyPolicy.route) }
|
||||
RowDivider()
|
||||
SettingRow(text = "Terms and Conditions") { navController.navigate(Routes.TermsAndConditions.route) }
|
||||
|
||||
SectionSpacer()
|
||||
|
||||
SettingRow(text = "Manage Account") { showManageAccountDialog.value = true }
|
||||
RowDivider()
|
||||
SettingRow(text = "Logout", includeIcon = false) { showLogoutDialog.value = true }
|
||||
RowDivider()
|
||||
}
|
||||
|
||||
if (showLogoutDialog.value) {
|
||||
LogoutDialog { performLogout ->
|
||||
if (performLogout) {
|
||||
loginViewModel.logout()
|
||||
}
|
||||
showLogoutDialog.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (showManageAccountDialog.value) {
|
||||
ManageAccountDialog(
|
||||
onDismiss = { showManageAccountDialog.value = false },
|
||||
settingsViewModel = settingsViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogoutButton(actionHandler: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
private fun RowDivider() {
|
||||
Divider(
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
|
||||
)
|
||||
}
|
||||
|
||||
Button(onClick = {
|
||||
// Sign out google users
|
||||
val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
|
||||
.build()
|
||||
@Composable
|
||||
private fun SectionSpacer() {
|
||||
RowDivider()
|
||||
Spacer(Modifier.height(60.dp))
|
||||
RowDivider()
|
||||
}
|
||||
|
||||
val googleSignIn = GoogleSignIn.getClient(context, signInOptions)
|
||||
googleSignIn.signOut()
|
||||
@Composable
|
||||
private fun SettingRow(text: String, includeIcon: Boolean = true, tapAction: () -> Unit) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { tapAction() }
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(16.dp),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
actionHandler()
|
||||
}) {
|
||||
Text(text = "Logout")
|
||||
if (includeIcon) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.chevron_right),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
package app.omnivore.omnivore.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.omnivore.omnivore.DatastoreKeys
|
||||
import app.omnivore.omnivore.DatastoreRepository
|
||||
import app.omnivore.omnivore.dataService.DataService
|
||||
import app.omnivore.omnivore.networking.Networker
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val networker: Networker,
|
||||
private val dataService: DataService,
|
||||
private val datastoreRepo: DatastoreRepository
|
||||
): ViewModel() {
|
||||
fun resetDataCache() {
|
||||
viewModelScope.launch {
|
||||
datastoreRepo.clearValue(DatastoreKeys.libraryLastSyncTimestamp)
|
||||
dataService.clearDatabase()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000" android:pathData="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z"/></vector>
|
||||
|
|
@ -0,0 +1 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000" android:pathData="M20 21H4V10H6V19H18V10H20V21M3 3H21V9H3V3M9.5 11H14.5C14.78 11 15 11.22 15 11.5V13H9V11.5C9 11.22 9.22 11 9.5 11M5 5V7H19V5H5Z"/></vector>
|
||||
|
|
@ -0,0 +1 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000" android:pathData="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z"/></vector>
|
||||
|
|
@ -0,0 +1 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000" android:pathData="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"/></vector>
|
||||
|
|
@ -0,0 +1 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000" android:pathData="M20.06,18C20,17.83 19.91,17.54 19.86,17.11C19.19,17.81 18.38,18.16 17.45,18.16C16.62,18.16 15.93,17.92 15.4,17.45C14.87,17 14.6,16.39 14.6,15.66C14.6,14.78 14.93,14.1 15.6,13.61C16.27,13.12 17.21,12.88 18.43,12.88H19.83V12.24C19.83,11.75 19.68,11.36 19.38,11.07C19.08,10.78 18.63,10.64 18.05,10.64C17.53,10.64 17.1,10.76 16.75,11C16.4,11.25 16.23,11.54 16.23,11.89H14.77C14.77,11.46 14.92,11.05 15.22,10.65C15.5,10.25 15.93,9.94 16.44,9.71C16.95,9.5 17.5,9.36 18.13,9.36C19.11,9.36 19.87,9.6 20.42,10.09C20.97,10.58 21.26,11.25 21.28,12.11V16C21.28,16.8 21.38,17.42 21.58,17.88V18H20.06M17.66,16.88C18.11,16.88 18.54,16.77 18.95,16.56C19.35,16.35 19.65,16.07 19.83,15.73V14.16H18.7C16.93,14.16 16.04,14.63 16.04,15.57C16.04,16 16.19,16.3 16.5,16.53C16.8,16.76 17.18,16.88 17.66,16.88M5.46,13.71H9.53L7.5,8.29L5.46,13.71M6.64,6H8.36L13.07,18H11.14L10.17,15.43H4.82L3.86,18H1.93L6.64,6Z"/></vector>
|
||||
1
android/Omnivore/app/src/main/res/drawable-v24/tag.xml
Normal file
1
android/Omnivore/app/src/main/res/drawable-v24/tag.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000" android:pathData="M21.41 11.58L12.41 2.58A2 2 0 0 0 11 2H4A2 2 0 0 0 2 4V11A2 2 0 0 0 2.59 12.42L11.59 21.42A2 2 0 0 0 13 22A2 2 0 0 0 14.41 21.41L21.41 14.41A2 2 0 0 0 22 13A2 2 0 0 0 21.41 11.58M13 20L4 11V4H11L20 13M6.5 5A1.5 1.5 0 1 1 5 6.5A1.5 1.5 0 0 1 6.5 5Z"/></vector>
|
||||
Loading…
Reference in a new issue