Merge pull request #1300 from omnivore-app/feature/web-display-options-android

Web Prefs - Android
This commit is contained in:
Satindar Dhillon 2022-10-20 13:20:02 -07:00 committed by GitHub
commit c77c570b05
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 425 additions and 51 deletions

View file

@ -17,8 +17,8 @@ android {
applicationId "app.omnivore.omnivore"
minSdk 23
targetSdk 32
versionCode 7
versionName "0.0.7"
versionCode 9
versionName "0.0.9"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View file

@ -1,5 +1,7 @@
package app.omnivore.omnivore
import app.omnivore.omnivore.ui.reader.WebFont
object Constants {
const val apiURL = BuildConfig.OMNIVORE_API_URL
const val webURL = BuildConfig.OMNIVORE_WEB_URL
@ -10,6 +12,11 @@ object DatastoreKeys {
const val omnivoreAuthToken = "omnivoreAuthToken"
const val omnivoreAuthCookieString = "omnivoreAuthCookieString"
const val omnivorePendingUserToken = "omnivorePendingUserToken"
const val preferredWebFontSize = "preferredWebFontSize"
const val preferredWebLineHeight = "preferredWebLineHeight"
const val preferredWebMaxWidthPercentage = "preferredWebMaxWidthPercentage"
const val preferredWebFontFamily = "preferredWebFontFamily"
const val prefersWebHighContrastText = "prefersWebHighContrastText"
}
object AppleConstants {

View file

@ -0,0 +1,184 @@
package app.omnivore.omnivore.ui.reader
import android.util.Log
import androidx.compose.foundation.clickable
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
import androidx.compose.material.Switch
import androidx.compose.material.Text
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.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import app.omnivore.omnivore.R
@Composable
fun WebPreferencesDialog(onDismiss: () -> Unit, webReaderViewModel: WebReaderViewModel) {
Dialog(onDismissRequest = { onDismiss() }) {
Surface(
shape = RoundedCornerShape(16.dp),
color = Color.White,
modifier = Modifier
.height(300.dp)
) {
WebPreferencesView(webReaderViewModel)
}
}
}
@Composable
fun WebPreferencesView(webReaderViewModel: WebReaderViewModel) {
val currentWebPreferences = webReaderViewModel.storedWebPreferences()
val isFontListExpanded = remember { mutableStateOf(false) }
val highContrastTextSwitchState = remember { mutableStateOf(currentWebPreferences.prefersHighContrastText) }
val selectedWebFontRawValue = remember { mutableStateOf(currentWebPreferences.fontFamily.rawValue) }
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("Web Preferences")
}
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
) {
Stepper(
label = "Font Size:",
onIncrease = { webReaderViewModel.updateFontSize(isIncrease = true) },
onDecrease = { webReaderViewModel.updateFontSize(isIncrease = false) }
)
Stepper(
label = "Margin:",
onIncrease = { webReaderViewModel.updateMaxWidthPercentage(isIncrease = false) },
onDecrease = { webReaderViewModel.updateMaxWidthPercentage(isIncrease = true) }
)
Stepper(
label = "Line Spacing:",
onIncrease = { webReaderViewModel.updateLineSpacing(isIncrease = true) },
onDecrease = { webReaderViewModel.updateLineSpacing(isIncrease = false) }
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("High Contrast Text")
Spacer(modifier = Modifier.weight(1.0F))
Switch(
checked = highContrastTextSwitchState.value,
onCheckedChange = {
highContrastTextSwitchState.value = it
webReaderViewModel.updateHighContrastTextPreference(it)
}
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(onClick = { isFontListExpanded.value = !isFontListExpanded.value })
) {
Text("Font Family")
Spacer(modifier = Modifier.weight(1.0F))
Icon(
imageVector =
if (isFontListExpanded.value)
Icons.Filled.KeyboardArrowDown
else
Icons.Filled.KeyboardArrowRight,
contentDescription = null
)
}
if (isFontListExpanded.value) {
WebFont.values().forEach {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(onClick = {
webReaderViewModel.applyWebFont(it)
selectedWebFontRawValue.value = it.rawValue
})
) {
Text(
it.displayText,
modifier = Modifier
.padding(top = 6.dp, start = 6.dp, end = 6.dp, bottom = 6.dp)
)
Spacer(modifier = Modifier.weight(1.0F))
if (it.rawValue == selectedWebFontRawValue.value) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null
)
}
}
}
}
}
}
}
@Composable
fun Stepper(label: String, onIncrease: () -> Unit, onDecrease: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = label,
modifier = Modifier
.padding(bottom = 6.dp)
)
Spacer(modifier = Modifier.weight(1.0F))
IconButton(onClick = { onDecrease() }) {
Icon(
painter = painterResource(id = R.drawable.minus),
contentDescription = null
)
}
Divider(
color = Color.Black,
modifier = Modifier
.height(20.dp)
.width(1.dp)
)
IconButton(onClick = { onIncrease() }) {
Icon(
painter = painterResource(id = R.drawable.plus),
contentDescription = null
)
}
}
}
data class WebPreferences(
val textFontSize: Int,
val lineHeight: Int,
val maxWidthPercentage: Int,
val themeKey: String,
val fontFamily: WebFont,
val prefersHighContrastText: Boolean
)

View file

@ -8,28 +8,115 @@ import android.view.*
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewmodel.compose.viewModel
import app.omnivore.omnivore.R
import com.google.gson.Gson
import java.util.*
import kotlin.math.roundToInt
@Composable
fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewModel) {
var showWebPreferencesDialog by remember { mutableStateOf(false ) }
val webReaderParams: WebReaderParams? by webReaderViewModel.webReaderParamsLiveData.observeAsState(null)
val annotation: String? by webReaderViewModel.annotationLiveData.observeAsState(null)
val maxToolbarHeight = 48.dp
val maxToolbarHeightPx = with(LocalDensity.current) { maxToolbarHeight.roundToPx().toFloat() }
val toolbarHeightPx = remember { mutableStateOf(maxToolbarHeightPx) }
// Create a connection to the nested scroll system and listen to the scroll happening inside child Column
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newHeight = toolbarHeightPx.value + delta
toolbarHeightPx.value = newHeight.coerceIn(0f, maxToolbarHeightPx)
return Offset.Zero
}
}
}
if (webReaderParams == null) {
webReaderViewModel.loadItem(slug = slug)
}
if (webReaderParams != null) {
WebReader(webReaderParams!!, webReaderViewModel)
Box(
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(webReaderViewModel.scrollState)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.requiredHeight(height = maxToolbarHeight)
) {
}
WebReader(webReaderParams!!, webReaderViewModel.storedWebPreferences(), webReaderViewModel)
}
TopAppBar(
modifier = Modifier
.height(height = with(LocalDensity.current) {
toolbarHeightPx.value.roundToInt().toDp()
} ),
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
title = {},
actions = {
IconButton(onClick = { showWebPreferencesDialog = true }) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = null
)
}
}
)
if (showWebPreferencesDialog) {
WebPreferencesDialog(
onDismiss = {
showWebPreferencesDialog = false
},
webReaderViewModel = webReaderViewModel
)
}
if (annotation != null) {
AnnotationEditView(
initialAnnotation = annotation!!,
onSave = {
webReaderViewModel.saveAnnotation(it)
},
onCancel = {
webReaderViewModel.cancelAnnotationEdit()
}
)
}
}
} else {
// TODO: add a proper loading view
Text("Loading...")
@ -38,23 +125,22 @@ fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewMod
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun WebReader(params: WebReaderParams, webReaderViewModel: WebReaderViewModel) {
// TODO: maybe handle cases where js can be queued up?
val javascriptToExecute = remember { mutableStateOf<String?>(null) }
val annotation: String? by webReaderViewModel.annotationLiveData.observeAsState(null)
fun WebReader(
params: WebReaderParams,
preferences: WebPreferences,
webReaderViewModel: WebReaderViewModel
) {
val javascriptActionLoopUUID: UUID by webReaderViewModel
.javascriptActionLoopUUIDLiveData
.observeAsState(UUID.randomUUID())
WebView.setWebContentsDebuggingEnabled(true)
val webReaderContent = WebReaderContent(
textFontSize = 12,
lineHeight = 150,
maxWidthPercentage = 100,
preferences = preferences,
item = params.item,
themeKey = "LightGray",
fontFamily = WebFont.SYSTEM ,
articleContent = params.articleContent,
prefersHighContrastText = false,
)
val styledContent = webReaderContent.styledContent()
@ -100,24 +186,14 @@ fun WebReader(params: WebReaderParams, webReaderViewModel: WebReaderViewModel) {
)
}
}, update = {
if (javascriptToExecute.value != null) {
it.evaluateJavascript(javascriptToExecute.value!!, null)
if (javascriptActionLoopUUID != webReaderViewModel.lastJavascriptActionLoopUUID) {
for (script in webReaderViewModel.javascriptDispatchQueue) {
Log.d("js", "executing script: $script")
it.evaluateJavascript(script, null)
}
webReaderViewModel.resetJavascriptDispatchQueue()
}
})
if (annotation != null) {
AnnotationEditView(
initialAnnotation = annotation!!,
onSave = {
val script = "var event = new Event('saveAnnotation');event.annotation = '$it';document.dispatchEvent(event);"
javascriptToExecute.value = script
webReaderViewModel.cancelAnnotationEdit()
},
onCancel = {
webReaderViewModel.cancelAnnotationEdit()
}
)
}
}
}

View file

@ -1,6 +1,5 @@
package app.omnivore.omnivore.ui.reader
import android.util.Log
import app.omnivore.omnivore.models.Highlight
import app.omnivore.omnivore.models.LinkedItem
import com.google.gson.Gson
@ -15,7 +14,6 @@ enum class WebFont(val displayText: String, val rawValue: String) {
ROBOTO("Roboto", "Roboto"),
CRIMSON_TEXT("Crimson Text", "Crimson Text"),
SOURCE_SERIF_PRO("Source Serif Pro", "Source Serif Pro"),
Inter("Inter", "Inter"),
}
enum class ArticleContentStatus(val rawValue: String) {
@ -39,29 +37,28 @@ data class ArticleContent(
}
data class WebReaderContent(
val textFontSize: Int,
val lineHeight: Int,
val maxWidthPercentage: Int,
val preferences: WebPreferences,
val item: LinkedItem,
val themeKey: String,
val fontFamily: WebFont,
val articleContent: ArticleContent,
val prefersHighContrastText: Boolean
) {
fun styledContent(): String {
// TODO: Kotlinize these three values (pasted from Swift)
val savedAt = "new Date(1662571290735.0).toISOString()"
val createdAt = "new Date().toISOString()"
val publishedAt = "new Date().toISOString()" //if (item.publishDate != null) "new Date((item.publishDate!.timeIntervalSince1970 * 1000)).toISOString()" else "undefined"
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 (themeKey == "Gray") "-dark" else ""}.css"
val content = """
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no' />
<style>
@import url("highlight${if (themeKey == "Gray") "-dark" else ""}.css");
@import url("highlightCssFilePath");
</style>
</head>
<body>
@ -96,11 +93,11 @@ data class WebReaderContent(
}
window.fontSize = $textFontSize
window.fontFamily = "${fontFamily.rawValue}"
window.maxWidthPercentage = $maxWidthPercentage
window.lineHeight = $lineHeight
window.fontFamily = "${preferences.fontFamily.rawValue}"
window.maxWidthPercentage = $preferences.maxWidthPercentage
window.lineHeight = $preferences.lineHeight
window.localStorage.setItem("theme", "$themeKey")
window.prefersHighContrastFont = $prefersHighContrastText
window.prefersHighContrastFont = $preferences.prefersHighContrastText
window.enableHighlightBar = false
</script>
<script src="bundle.js"></script>
@ -109,7 +106,5 @@ data class WebReaderContent(
</body>
</html>
"""
return content
}
}

View file

@ -1,16 +1,20 @@
package app.omnivore.omnivore.ui.reader
import android.util.Log
import androidx.compose.foundation.ScrollState
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.DatastoreRepository
import app.omnivore.omnivore.models.LinkedItem
import app.omnivore.omnivore.networking.*
import com.google.gson.Gson
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.json.JSONObject
import java.util.*
import javax.inject.Inject
data class WebReaderParams(
@ -27,8 +31,13 @@ class WebReaderViewModel @Inject constructor(
private val datastoreRepo: DatastoreRepository,
private val networker: Networker
): ViewModel() {
var lastJavascriptActionLoopUUID: UUID = UUID.randomUUID()
var javascriptDispatchQueue: MutableList<String> = mutableListOf()
var scrollState = ScrollState(0)
val webReaderParamsLiveData = MutableLiveData<WebReaderParams?>(null)
val annotationLiveData = MutableLiveData<String?>(null)
val javascriptActionLoopUUIDLiveData = MutableLiveData(lastJavascriptActionLoopUUID)
fun loadItem(slug: String) {
viewModelScope.launch {
@ -90,9 +99,110 @@ class WebReaderViewModel @Inject constructor(
fun reset() {
webReaderParamsLiveData.value = null
annotationLiveData.value = null
scrollState = ScrollState(0)
javascriptDispatchQueue = mutableListOf()
}
fun resetJavascriptDispatchQueue() {
lastJavascriptActionLoopUUID = javascriptActionLoopUUIDLiveData.value ?: UUID.randomUUID()
javascriptDispatchQueue = mutableListOf()
}
fun saveAnnotation(annotation: String) {
val script = "var event = new Event('saveAnnotation');event.annotation = '$annotation';document.dispatchEvent(event);"
enqueueScript(script)
cancelAnnotationEdit()
}
fun cancelAnnotationEdit() {
annotationLiveData.value = null
}
private fun enqueueScript(javascript: String) {
javascriptDispatchQueue.add(javascript)
javascriptActionLoopUUIDLiveData.value = UUID.randomUUID()
}
fun storedWebPreferences(): WebPreferences = runBlocking {
val storedFontSize = datastoreRepo.getInt(DatastoreKeys.preferredWebFontSize)
val storedLineHeight = datastoreRepo.getInt(DatastoreKeys.preferredWebLineHeight)
val storedMaxWidth = datastoreRepo.getInt(DatastoreKeys.preferredWebMaxWidthPercentage)
val storedFontFamily = datastoreRepo.getString(DatastoreKeys.preferredWebFontFamily) ?: WebFont.SYSTEM.rawValue
val storedWebFont = WebFont.values().first { it.rawValue == storedFontFamily }
val prefersHighContrastFont = datastoreRepo.getString(DatastoreKeys.prefersWebHighContrastText) == "true"
WebPreferences(
textFontSize = storedFontSize ?: 12,
lineHeight = storedLineHeight ?: 150,
maxWidthPercentage = storedMaxWidth ?: 100,
themeKey = "LightGray", // TODO: match system value
fontFamily = storedWebFont,
prefersHighContrastText = prefersHighContrastFont
)
}
fun updateFontSize(isIncrease: Boolean) {
val delta = if (isIncrease) 2 else -2
var newFontSize: Int
runBlocking {
val storedFontSize = datastoreRepo.getInt(DatastoreKeys.preferredWebFontSize)
newFontSize = ((storedFontSize ?: 12) + delta).coerceIn(8, 28)
datastoreRepo.putInt(DatastoreKeys.preferredWebFontSize, newFontSize)
}
// Get value from data store and then update it
val script = "var event = new Event('updateFontSize');event.fontSize = '$newFontSize';document.dispatchEvent(event);"
enqueueScript(script)
}
fun updateMaxWidthPercentage(isIncrease: Boolean) {
val delta = if (isIncrease) 10 else -10
var newMaxWidthPercentageValue: Int
runBlocking {
val storedWidth = datastoreRepo.getInt(DatastoreKeys.preferredWebMaxWidthPercentage)
newMaxWidthPercentageValue = ((storedWidth ?: 100) + delta).coerceIn(40, 100)
datastoreRepo.putInt(DatastoreKeys.preferredWebMaxWidthPercentage, newMaxWidthPercentageValue)
}
// Get value from data store and then update it
val script = "var event = new Event('updateMaxWidthPercentage');event.maxWidthPercentage = '$newMaxWidthPercentageValue';document.dispatchEvent(event);"
enqueueScript(script)
}
fun updateLineSpacing(isIncrease: Boolean) {
val delta = if (isIncrease) 25 else -25
var newLineHeight: Int
runBlocking {
val storedHeight = datastoreRepo.getInt(DatastoreKeys.preferredWebLineHeight)
newLineHeight = ((storedHeight ?: 150) + delta).coerceIn(100, 300)
datastoreRepo.putInt(DatastoreKeys.preferredWebLineHeight, newLineHeight)
}
// Get value from data store and then update it
val script = "var event = new Event('updateLineHeight');event.lineHeight = '$newLineHeight';document.dispatchEvent(event);"
enqueueScript(script)
}
fun updateHighContrastTextPreference(prefersHighContrastText: Boolean) {
runBlocking {
datastoreRepo.putString(DatastoreKeys.prefersWebHighContrastText, prefersHighContrastText.toString())
}
val fontContrastValue = if (prefersHighContrastText) "high" else "normal"
val script = "var event = new Event('handleFontContrastChange');event.fontContrast = '$fontContrastValue';document.dispatchEvent(event);"
enqueueScript(script)
}
fun applyWebFont(font: WebFont) {
runBlocking {
datastoreRepo.putString(DatastoreKeys.preferredWebFontFamily, font.rawValue)
}
val script = "var event = new Event('updateFontFamily');event.fontFamily = '${font.rawValue}';document.dispatchEvent(event);"
enqueueScript(script)
}
}

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="M19,13H5V11H19V13Z"/></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="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z"/></vector>