Merge pull request #1838 from omnivore-app/feature/android-labels

Label Filtering - Android
This commit is contained in:
Jackson Harper 2023-04-13 10:53:14 +08:00 committed by GitHub
commit 0962ae3deb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1586 additions and 137 deletions

View file

@ -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

View file

@ -0,0 +1,12 @@
mutation SetLabels($input: SetLabelsInput!) {
setLabels(input: $input) {
... on SetLabelsSuccess {
labels {
...LabelFields
}
}
... on SetLabelsError {
errorCodes
}
}
}

View file

@ -0,0 +1,16 @@
mutation CreateLabel($input: CreateLabelInput!) {
createLabel(input: $input) {
... on CreateLabelSuccess {
label {
id
name
color
description
createdAt
}
}
... on CreateLabelError {
errorCodes
}
}
}

View file

@ -0,0 +1,12 @@
query GetLabels {
labels {
... on LabelsSuccess {
labels {
...LabelFields
}
}
... on LabelsError {
errorCodes
}
}
}

View file

@ -17,6 +17,7 @@ object DatastoreKeys {
const val prefersWebHighContrastText = "prefersWebHighContrastText"
const val lastUsedSavedItemFilter = "lastUsedSavedItemFilter"
const val lastUsedSavedItemSortFilter = "lastUsedSavedItemSortFilter"
const val preferredTheme = "preferredTheme"
}
object AppleConstants {

View file

@ -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)
}
}
}

View file

@ -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")
}

View file

@ -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 {

View file

@ -63,6 +63,12 @@ fun DataService.libraryLiveData(
}
}
}
if (labels.isNotEmpty()) {
mediatorLiveData.value = (mediatorLiveData.value ?: listOf()).filter {
it.labels.intersect(labels.toSet()).any()
}
}
}
return mediatorLiveData

View file

@ -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,

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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())
}
}

View file

@ -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

View file

@ -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

View file

@ -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
)
}
}
}

View file

@ -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"
)
}

View file

@ -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")
}
}
}
}
}
}
}

View file

@ -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
},
)
}
}
}
}

View file

@ -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(

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
)
}

View file

@ -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
)

View file

@ -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()
}

View file

@ -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}")

View file

@ -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) {

View file

@ -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))
}
}
}
}
}

View file

@ -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")
}
}
}

View file

@ -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(

View file

@ -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
)
}

View file

@ -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")
}
}
)
}

View file

@ -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)
}
}
}
}

View file

@ -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 = {}
)
}
}
}

View file

@ -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
)
}
}
}

View file

@ -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()
}
}
}

View 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="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>

View 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="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>

View 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="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z"/></vector>

View 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="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>

View 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="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>

View 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>