summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Settings.kt7
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt8
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt30
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt87
-rw-r--r--app/src/test/java/org/terst/feedreader/data/SettingsTest.kt32
5 files changed, 140 insertions, 24 deletions
diff --git a/app/src/main/java/org/terst/feedreader/data/Settings.kt b/app/src/main/java/org/terst/feedreader/data/Settings.kt
index be3e809..ebeba6c 100644
--- a/app/src/main/java/org/terst/feedreader/data/Settings.kt
+++ b/app/src/main/java/org/terst/feedreader/data/Settings.kt
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
+import kotlin.math.roundToInt
val Context.settingsDataStore by preferencesDataStore(name = "settings")
@@ -38,6 +39,12 @@ private const val READER_FONT_SIZE_MIN_PX = 14
private const val READER_FONT_SIZE_MAX_PX = 28
val READER_FONT_SIZE_RANGE_PX = READER_FONT_SIZE_MIN_PX..READER_FONT_SIZE_MAX_PX
+// Pure so the pinch-to-zoom math is testable without a WebView/gesture detector in the loop --
+// ReaderWebView.kt calls this once per gesture (at pinch-end, with the cumulative scale factor
+// accumulated across the whole gesture), not per touch-move frame.
+fun pinchZoomFontSizePx(basePx: Int, scaleFactor: Float): Int =
+ (basePx * scaleFactor).roundToInt().coerceIn(READER_FONT_SIZE_RANGE_PX.first, READER_FONT_SIZE_RANGE_PX.last)
+
/** Stash connection settings, configured on the Settings screen. Both blank until set. */
class Settings(private val context: Context) {
val stashBaseUrl: Flow<String> =
diff --git a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
index 0f7540d..d71b020 100644
--- a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
@@ -234,9 +234,11 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un
factory = { ctx ->
newReaderWebView(
ctx,
- onOpenLink,
- onLongPressLink = { longPressedLink = it },
- onYouTubeVideo = { youtubeVideoId = it }
+ ReaderWebViewCallbacks(
+ onOpenLink = onOpenLink,
+ onLongPressLink = { longPressedLink = it },
+ onYouTubeVideo = { youtubeVideoId = it }
+ )
).apply {
loadDataWithBaseURL(url, wrapReaderHtml(currentExtractedContent, extractedTitle), "text/html", "UTF-8", null)
loadedContent.value = currentExtractedContent
diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
index a89052c..e2b8aa9 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
@@ -46,6 +46,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -180,6 +181,10 @@ private fun ReaderContent(
) {
val context = LocalContext.current
val db = remember { AppDatabase.get(context) }
+ // Independently remembered rather than threaded down from ReaderScreen, matching db/scope
+ // above -- Settings is a stateless DataStore wrapper, cheap to construct again, and this
+ // keeps pinch-to-zoom from growing ReaderContent's already-long parameter list any further.
+ val settings = remember { Settings(context) }
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
@@ -526,19 +531,28 @@ private fun ReaderContent(
// changing it in Settings and coming back to an already-open article
// picks up the new size, without reloading on every unrelated recomposition.
val loadedKey = remember(articleId) { mutableStateOf<Pair<String?, Int>?>(null) }
+ // newReaderWebView's factory runs once per WebView instance, but fontSizePx can
+ // change later (e.g. the user tweaks it in Settings while this same article stays
+ // open) -- rememberUpdatedState keeps the pinch gesture's "what am I scaling from"
+ // read current instead of frozen at whatever it was on first composition.
+ val latestFontSizePx by rememberUpdatedState(fontSizePx)
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
newReaderWebView(
ctx,
- onOpenLink,
- onLongPressLink = { longPressedLink = it },
- onYouTubeVideo = { youtubeVideoId = it },
- onScrollChanged = { scrollY -> ReaderScrollPositions.set(articleId, scrollY) },
- // The page's real content height isn't known until layout finishes
- // -- scrolling to a saved offset any earlier would just clamp back
- // to 0 on a page that hasn't grown tall enough yet.
- onPageFinished = { webView -> webView.scrollTo(0, ReaderScrollPositions.get(articleId)) }
+ ReaderWebViewCallbacks(
+ onOpenLink = onOpenLink,
+ onLongPressLink = { longPressedLink = it },
+ onYouTubeVideo = { youtubeVideoId = it },
+ onScrollChanged = { scrollY -> ReaderScrollPositions.set(articleId, scrollY) },
+ // The page's real content height isn't known until layout finishes
+ // -- scrolling to a saved offset any earlier would just clamp back
+ // to 0 on a page that hasn't grown tall enough yet.
+ onPageFinished = { webView -> webView.scrollTo(0, ReaderScrollPositions.get(articleId)) },
+ currentFontSizePx = { latestFontSizePx },
+ onFontSizeChanged = { newSizePx -> scope.launch { settings.setReaderFontSizePx(newSizePx) } }
+ )
).apply {
val html = wrapReaderHtml(content, article?.title, article?.publishedAt, article?.imageUrl, fontSizePx)
loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null)
diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
index b13fc58..039255f 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
@@ -3,6 +3,7 @@ package org.terst.feedreader.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
+import android.view.ScaleGestureDetector
import android.view.View
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
@@ -23,6 +24,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import org.terst.feedreader.data.DEFAULT_READER_FONT_SIZE_PX
+import org.terst.feedreader.data.pinchZoomFontSizePx
// In-memory only (process lifetime, not persisted to disk) -- scoped to "don't lose my
// place if Next/Previous takes me away and back," not to surviving an app restart. A
@@ -76,15 +78,44 @@ private fun extractYouTubeVideoId(uri: Uri): String? {
* YouTubePlayerDialog instead, so watching a video doesn't leave the reader. Falls
* back to the external-open behavior when a confident video ID can't be extracted
* (a playlist/channel link, say).
+ *
+ * Pinch-to-zoom adjusts the persisted reader font size, not WebView's own page zoom
+ * (never enabled here -- setSupportZoom/builtInZoomControls are left at their defaults,
+ * which is effectively off). A real page zoom would scale layout and images along with
+ * text and wouldn't survive the next article/reload; this app already has a font-size
+ * concept the rest of the reader respects (Settings.readerFontSizePx, adjustable from
+ * the Settings screen), so pinching should drive that same value instead of inventing a
+ * second, WebView-local notion of "how big does this look right now."
+ *
+ * Getting this right without breaking ordinary one-finger scrolling/tapping is the whole
+ * trick: a ScaleGestureDetector is fed every touch via setOnTouchListener, but that
+ * listener always returns false (never consumes the event), so WebView's own touch
+ * handling -- scrolling, link taps, the long-press-link listener above -- proceeds
+ * exactly as before. ScaleGestureDetector only calls back once a second pointer is down,
+ * so a single-finger drag never triggers any of this.
+ *
+ * currentFontSizePx is read fresh (not captured once) when a gesture ends, since the
+ * WebView this attaches to is created once but can outlive a Settings-screen-driven font
+ * size change made while this same article is still open.
+ *
+ * During the gesture the WebView is visually scaled live (View.scaleX/scaleY) for
+ * immediate feedback -- cheap, and independent of the WebView's own content layout.
+ * At gesture end the scale resets to 1x and onFontSizeChanged reports the new,
+ * clamped, rounded font size for the caller to persist; ReaderScreen's existing
+ * content-vs-fontSizePx reload logic (see ReaderScreen.ReaderContent) then re-renders
+ * the actual HTML at that size.
*/
-fun newReaderWebView(
- context: Context,
- onOpenLink: (String) -> Unit,
- onLongPressLink: (String) -> Unit = {},
- onYouTubeVideo: (String) -> Unit = {},
- onScrollChanged: (Int) -> Unit = {},
- onPageFinished: (WebView) -> Unit = {}
-): WebView =
+data class ReaderWebViewCallbacks(
+ val onOpenLink: (String) -> Unit,
+ val onLongPressLink: (String) -> Unit = {},
+ val onYouTubeVideo: (String) -> Unit = {},
+ val onScrollChanged: (Int) -> Unit = {},
+ val onPageFinished: (WebView) -> Unit = {},
+ val currentFontSizePx: () -> Int = { DEFAULT_READER_FONT_SIZE_PX },
+ val onFontSizeChanged: (Int) -> Unit = {}
+)
+
+fun newReaderWebView(context: Context, callbacks: ReaderWebViewCallbacks): WebView =
WebView(context).apply {
settings.javaScriptEnabled = false
overScrollMode = View.OVER_SCROLL_NEVER
@@ -95,11 +126,41 @@ fun newReaderWebView(
// Next/Previous replaces this screen's back-stack entry with a fresh one for the same
// article, which recreates the WebView from scratch and would otherwise always land
// back at the top.
- setOnScrollChangeListener { _, _, scrollY, _, _ -> onScrollChanged(scrollY) }
+ setOnScrollChangeListener { _, _, scrollY, _, _ -> callbacks.onScrollChanged(scrollY) }
+
+ var pinchScale = 1f
+ val webView = this
+ val scaleDetector = ScaleGestureDetector(
+ context,
+ object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
+ override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
+ pinchScale = 1f
+ return true
+ }
+
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
+ pinchScale *= detector.scaleFactor
+ webView.scaleX = pinchScale
+ webView.scaleY = pinchScale
+ return true
+ }
+
+ override fun onScaleEnd(detector: ScaleGestureDetector) {
+ val newSizePx = pinchZoomFontSizePx(callbacks.currentFontSizePx(), pinchScale)
+ webView.scaleX = 1f
+ webView.scaleY = 1f
+ callbacks.onFontSizeChanged(newSizePx)
+ }
+ }
+ )
+ setOnTouchListener { _, event ->
+ scaleDetector.onTouchEvent(event)
+ false // never consume -- WebView still handles scroll/tap/long-press normally
+ }
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
- onPageFinished(view)
+ callbacks.onPageFinished(view)
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
@@ -117,12 +178,12 @@ fun newReaderWebView(
if (isYouTubeWatchUrl(url)) {
val videoId = extractYouTubeVideoId(url)
if (videoId != null) {
- onYouTubeVideo(videoId)
+ callbacks.onYouTubeVideo(videoId)
} else {
context.startActivity(Intent(Intent.ACTION_VIEW, url))
}
} else {
- onOpenLink(url.toString())
+ callbacks.onOpenLink(url.toString())
}
return true
}
@@ -134,7 +195,7 @@ fun newReaderWebView(
else -> null
}
if (url != null) {
- onLongPressLink(url)
+ callbacks.onLongPressLink(url)
true
} else {
false
diff --git a/app/src/test/java/org/terst/feedreader/data/SettingsTest.kt b/app/src/test/java/org/terst/feedreader/data/SettingsTest.kt
new file mode 100644
index 0000000..5b2048b
--- /dev/null
+++ b/app/src/test/java/org/terst/feedreader/data/SettingsTest.kt
@@ -0,0 +1,32 @@
+package org.terst.feedreader.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class SettingsTest {
+
+ @Test
+ fun `pinch zoom with scale factor of 1 leaves the font size unchanged`() {
+ assertEquals(18, pinchZoomFontSizePx(18, 1.0f))
+ }
+
+ @Test
+ fun `pinching out scales the font size up, rounded to the nearest pixel`() {
+ assertEquals(22, pinchZoomFontSizePx(20, 1.1f))
+ }
+
+ @Test
+ fun `pinching in scales the font size down, rounded to the nearest pixel`() {
+ assertEquals(16, pinchZoomFontSizePx(20, 0.8f))
+ }
+
+ @Test
+ fun `pinch zoom result is clamped to the minimum reader font size`() {
+ assertEquals(READER_FONT_SIZE_RANGE_PX.first, pinchZoomFontSizePx(14, 0.3f))
+ }
+
+ @Test
+ fun `pinch zoom result is clamped to the maximum reader font size`() {
+ assertEquals(READER_FONT_SIZE_RANGE_PX.last, pinchZoomFontSizePx(28, 3.0f))
+ }
+}