diff --git a/android/Omnivore/app/build.gradle b/android/Omnivore/app/build.gradle index 66cccad8f..ef847fd4d 100644 --- a/android/Omnivore/app/build.gradle +++ b/android/Omnivore/app/build.gradle @@ -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 { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt index 3b219d6a3..075eb297b 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt @@ -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 { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebPreferencesDialog.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebPreferencesDialog.kt new file mode 100644 index 000000000..c2466dbe0 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebPreferencesDialog.kt @@ -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 +) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt index 20e3e0439..afff5de0f 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt @@ -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(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() - } - ) - } } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderContent.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderContent.kt index 599c55078..69bfb484f 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderContent.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderContent.kt @@ -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 """ @@ -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 @@ -109,7 +106,5 @@ data class WebReaderContent( """ - - return content } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt index e9a6f4aaf..fa816a96f 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt @@ -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 = mutableListOf() + var scrollState = ScrollState(0) + val webReaderParamsLiveData = MutableLiveData(null) val annotationLiveData = MutableLiveData(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) + } } diff --git a/android/Omnivore/app/src/main/res/drawable-v24/minus.xml b/android/Omnivore/app/src/main/res/drawable-v24/minus.xml new file mode 100644 index 000000000..7ac604f6a --- /dev/null +++ b/android/Omnivore/app/src/main/res/drawable-v24/minus.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/Omnivore/app/src/main/res/drawable-v24/plus.xml b/android/Omnivore/app/src/main/res/drawable-v24/plus.xml new file mode 100644 index 000000000..a476f560f --- /dev/null +++ b/android/Omnivore/app/src/main/res/drawable-v24/plus.xml @@ -0,0 +1 @@ + \ No newline at end of file