Add HTML reader from Feeder (#65)

* Add HTML reader from Feeder

Thanks to the Feeder!

* Apply ScrollBar

* Add share menu

* Update README
This commit is contained in:
Ashinch 2022-05-13 10:16:55 +08:00 committed by GitHub
parent b7813d45f4
commit b31e7eb98e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1836 additions and 151 deletions

View File

@ -90,7 +90,8 @@
- [Readability4J](https://github.com/dankito/Readability4J): [Apache License 2.0](https://github.com/dankito/Readability4J/blob/master/LICENSE)
- [opml-parser](https://github.com/mdewilde/opml-parser): [Apache License 2.0](https://github.com/mdewilde/opml-parser/blob/master/LICENSE)
- [compose-html](https://github.com/ireward/compose-html): [Apache License 2.0](https://github.com/ireward/compose-html/blob/main/LICENSE.txt)
- (待完善)
- [Rome](https://github.com/rometools/rome): [Apache License 2.0](https://github.com/rometools/rome/blob/master/LICENSE)
- [Feeder](https://gitlab.com/spacecowboy/Feeder): [GPL v3.0](https://gitlab.com/spacecowboy/Feeder/-/blob/master/LICENSE)
## 许可证

View File

@ -90,7 +90,8 @@ The following are the progress made so far and the goals to be worked on in the
- [Readability4J](https://github.com/dankito/Readability4J): [Apache License 2.0](https://github.com/dankito/Readability4J/blob/master/LICENSE)
- [opml-parser](https://github.com/mdewilde/opml-parser): [Apache License 2.0](https://github.com/mdewilde/opml-parser/blob/master/LICENSE)
- [compose-html](https://github.com/ireward/compose-html): [Apache License 2.0](https://github.com/ireward/compose-html/blob/main/LICENSE.txt)
- To be improved
- [Rome](https://github.com/rometools/rome): [Apache License 2.0](https://github.com/rometools/rome/blob/master/LICENSE)
- [Feeder](https://gitlab.com/spacecowboy/Feeder): [GPL v3.0](https://gitlab.com/spacecowboy/Feeder/-/blob/master/LICENSE)
## License

View File

@ -101,6 +101,7 @@ dependencies {
// https://coil-kt.github.io/coil/changelog/
implementation("io.coil-kt:coil-compose:$coil")
implementation("io.coil-kt:coil-svg:$coil")
implementation("io.coil-kt:coil-gif:$coil")
// https://square.github.io/okhttp/changelogs/changelog/
implementation "com.squareup.okhttp3:okhttp:5.0.0-alpha.6"

View File

@ -1,10 +1,24 @@
package me.ash.reader
import android.app.Application
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.os.Build
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.WorkManager
import coil.ComponentRegistry
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import coil.request.*
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -18,7 +32,7 @@ import me.ash.reader.ui.ext.*
import javax.inject.Inject
@HiltAndroidApp
class App : Application(), Configuration.Provider {
class App : Application(), Configuration.Provider, ImageLoader {
@Inject
lateinit var readerDatabase: ReaderDatabase
@ -108,4 +122,58 @@ class App : Application(), Configuration.Provider {
.setWorkerFactory(workerFactory)
.setMinimumLoggingLevel(android.util.Log.DEBUG)
.build()
override val components: ComponentRegistry
get() = ComponentRegistry.Builder()
.add(SvgDecoder.Factory())
.add(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoderDecoder.Factory()
} else {
GifDecoder.Factory()
}
)
.build()
override val defaults: DefaultRequestOptions
get() = DefaultRequestOptions()
override val diskCache: DiskCache
get() = DiskCache.Builder()
.directory(cacheDir.resolve("images"))
.maxSizePercent(0.02)
.build()
override val memoryCache: MemoryCache
get() = MemoryCache.Builder(this)
.maxSizePercent(0.25)
.build()
override fun enqueue(request: ImageRequest): Disposable {
// Always call onStart before onSuccess.
request.target?.onStart(request.placeholder)
val result = ColorDrawable(Color.BLACK)
request.target?.onSuccess(result)
return object : Disposable {
override val job = CompletableDeferred(newResult(request, result))
override val isDisposed get() = true
override fun dispose() {}
}
}
override suspend fun execute(request: ImageRequest): ImageResult {
return newResult(request, ColorDrawable(Color.BLACK))
}
override fun newBuilder(): ImageLoader.Builder {
throw UnsupportedOperationException()
}
override fun shutdown() {
}
private fun newResult(request: ImageRequest, drawable: Drawable): SuccessResult {
return SuccessResult(
drawable = drawable,
request = request,
dataSource = DataSource.MEMORY_CACHE
)
}
}

View File

@ -1,30 +1,19 @@
package me.ash.reader
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.view.WindowCompat
import androidx.profileinstaller.ProfileInstallerInitializer
import coil.ComponentRegistry
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import coil.request.*
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CompletableDeferred
import me.ash.reader.data.preference.LanguagesPreference
import me.ash.reader.data.preference.SettingsProvider
import me.ash.reader.ui.ext.languages
import me.ash.reader.ui.page.common.HomeEntry
@AndroidEntryPoint
class MainActivity : ComponentActivity(), ImageLoader {
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -43,49 +32,4 @@ class MainActivity : ComponentActivity(), ImageLoader {
}
}
}
override val components: ComponentRegistry
get() = ComponentRegistry.Builder().add(SvgDecoder.Factory()).build()
override val defaults: DefaultRequestOptions
get() = DefaultRequestOptions()
override val diskCache: DiskCache
get() = DiskCache.Builder()
.directory(this.cacheDir.resolve("images"))
.maxSizePercent(0.02)
.build()
override val memoryCache: MemoryCache
get() = MemoryCache.Builder(this)
.maxSizePercent(0.25)
.build()
override fun enqueue(request: ImageRequest): Disposable {
// Always call onStart before onSuccess.
request.target?.onStart(request.placeholder)
val result = ColorDrawable(Color.BLACK)
request.target?.onSuccess(result)
return object : Disposable {
override val job = CompletableDeferred(newResult(request, result))
override val isDisposed get() = true
override fun dispose() {}
}
}
override suspend fun execute(request: ImageRequest): ImageResult {
return newResult(request, ColorDrawable(Color.BLACK))
}
override fun newBuilder(): ImageLoader.Builder {
throw UnsupportedOperationException()
}
override fun shutdown() {
}
private fun newResult(request: ImageRequest, drawable: Drawable): SuccessResult {
return SuccessResult(
drawable = drawable,
request = request,
dataSource = DataSource.MEMORY_CACHE
)
}
}

View File

@ -0,0 +1,123 @@
package me.ash.reader.ui.component
import androidx.annotation.DrawableRes
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import coil.imageLoader
import coil.request.ImageRequest
import coil.size.Precision
import coil.size.Scale
import coil.size.Size
import me.ash.reader.R
@Composable
fun AsyncImage(
modifier: Modifier = Modifier,
data: Any? = null,
size: Size = Size.ORIGINAL,
scale: Scale = Scale.FIT,
precision: Precision = Precision.AUTOMATIC,
contentScale: ContentScale = ContentScale.Fit,
contentDescription: String = "",
@DrawableRes placeholder: Int? = R.drawable.ic_hourglass_empty_black_24dp,
@DrawableRes error: Int? = R.drawable.ic_broken_image_black_24dp,
) {
val context = LocalContext.current
coil.compose.AsyncImage(
modifier = modifier,
model = ImageRequest
.Builder(context)
.data(data)
.crossfade(true)
.scale(scale)
.precision(precision)
.size(size)
.build(),
contentDescription = contentDescription,
contentScale = contentScale,
imageLoader = context.imageLoader,
placeholder = placeholder?.let {
forwardingPainter(
painter = painterResource(it),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
alpha = 0.5f,
)
},
error = error?.let {
forwardingPainter(
painter = painterResource(it),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onError),
alpha = 0.5f
)
},
)
}
// From: https://gist.github.com/colinrtwhite/c2966e0b8584b4cdf0a5b05786b20ae1
/**
* Create and return a new [Painter] that wraps [painter] with its [alpha], [colorFilter], or [onDraw] overwritten.
*/
fun forwardingPainter(
painter: Painter,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
onDraw: DrawScope.(ForwardingDrawInfo) -> Unit = DefaultOnDraw,
): Painter = ForwardingPainter(painter, alpha, colorFilter, onDraw)
data class ForwardingDrawInfo(
val painter: Painter,
val alpha: Float,
val colorFilter: ColorFilter?,
)
private class ForwardingPainter(
private val painter: Painter,
private var alpha: Float,
private var colorFilter: ColorFilter?,
private val onDraw: DrawScope.(ForwardingDrawInfo) -> Unit,
) : Painter() {
private var info = newInfo()
override val intrinsicSize get() = painter.intrinsicSize
override fun applyAlpha(alpha: Float): Boolean {
if (alpha == DefaultAlpha) {
this.alpha = alpha
this.info = newInfo()
}
return true
}
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean {
if (colorFilter == null) {
this.colorFilter = colorFilter
this.info = newInfo()
}
return true
}
override fun DrawScope.onDraw() = onDraw(info)
private fun newInfo() = ForwardingDrawInfo(painter, alpha, colorFilter)
}
private val DefaultOnDraw: DrawScope.(ForwardingDrawInfo) -> Unit = { info ->
with(info.painter) {
draw(
androidx.compose.ui.geometry.Size(size.width, size.height),
info.alpha,
info.colorFilter
)
}
}

View File

@ -7,11 +7,7 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import coil.compose.AsyncImage
import coil.imageLoader
import coil.request.ImageRequest
import com.caverock.androidsvg.SVG
import me.ash.reader.data.preference.LocalDarkTheme
import me.ash.reader.ui.svg.parseDynamicColor
@ -23,7 +19,6 @@ fun DynamicSVGImage(
svgImageString: String,
contentDescription: String,
) {
val context = LocalContext.current
val useDarkTheme = LocalDarkTheme.current.isDarkTheme()
val tonalPalettes = LocalTonalPalettes.current
var size by remember { mutableStateOf(IntSize.Zero) }
@ -48,11 +43,9 @@ fun DynamicSVGImage(
Crossfade(targetState = pic) {
AsyncImage(
contentDescription = contentDescription,
model = ImageRequest.Builder(context)
.data(it)
.crossfade(true)
.build(),
imageLoader = context.imageLoader,
data = it,
placeholder = null,
error = null,
)
}
}

View File

@ -0,0 +1,191 @@
/*
* Feeder: Android RSS reader app
* https://gitlab.com/spacecowboy/Feeder
*
* Copyright (C) 2022 Jonas Kalderstam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package me.ash.reader.ui.component.reader
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
class AnnotatedParagraphStringBuilder {
// Private for a reason
private val builder: AnnotatedString.Builder = AnnotatedString.Builder()
private val poppedComposableStyles = mutableListOf<ComposableStyleWithStartEnd>()
val composableStyles = mutableListOf<ComposableStyleWithStartEnd>()
val lastTwoChars: MutableList<Char> = mutableListOf()
val length: Int
get() = builder.length
val endsWithWhitespace: Boolean
get() {
if (lastTwoChars.isEmpty()) {
return true
}
lastTwoChars.peekLatest()?.let { latest ->
// Non-breaking space (160) is not caught by trim or whitespace identification
if (latest.isWhitespace() || latest.code == 160) {
return true
}
}
return false
}
fun pushStyle(style: SpanStyle): Int =
builder.pushStyle(style = style)
fun pop(index: Int) =
builder.pop(index)
fun pushStringAnnotation(tag: String, annotation: String): Int =
builder.pushStringAnnotation(tag = tag, annotation = annotation)
fun pushComposableStyle(
style: @Composable () -> SpanStyle
): Int {
composableStyles.add(
ComposableStyleWithStartEnd(
style = style,
start = builder.length
)
)
return composableStyles.lastIndex
}
fun popComposableStyle(
index: Int
) {
poppedComposableStyles.add(
composableStyles.removeAt(index).copy(end = builder.length)
)
}
fun append(text: String) {
if (text.count() >= 2) {
lastTwoChars.pushMaxTwo(text.secondToLast())
}
if (text.isNotEmpty()) {
lastTwoChars.pushMaxTwo(text.last())
}
builder.append(text)
}
fun append(char: Char) {
lastTwoChars.pushMaxTwo(char)
builder.append(char)
}
@Composable
fun toAnnotatedString(): AnnotatedString {
for (composableStyle in poppedComposableStyles) {
builder.addStyle(
style = composableStyle.style(),
start = composableStyle.start,
end = composableStyle.end
)
}
for (composableStyle in composableStyles) {
builder.addStyle(
style = composableStyle.style(),
start = composableStyle.start,
end = builder.length
)
}
return builder.toAnnotatedString()
}
}
fun AnnotatedParagraphStringBuilder.isEmpty() = lastTwoChars.isEmpty()
fun AnnotatedParagraphStringBuilder.isNotEmpty() = lastTwoChars.isNotEmpty()
fun AnnotatedParagraphStringBuilder.ensureDoubleNewline() {
when {
lastTwoChars.isEmpty() -> {
// Nothing to do
}
length == 1 && lastTwoChars.peekLatest()?.isWhitespace() == true -> {
// Nothing to do
}
length == 2 &&
lastTwoChars.peekLatest()?.isWhitespace() == true &&
lastTwoChars.peekSecondLatest()?.isWhitespace() == true -> {
// Nothing to do
}
lastTwoChars.peekLatest() == '\n' && lastTwoChars.peekSecondLatest() == '\n' -> {
// Nothing to do
}
lastTwoChars.peekLatest() == '\n' -> {
append('\n')
}
else -> {
append("\n\n")
}
}
}
private fun AnnotatedParagraphStringBuilder.ensureSingleNewline() {
when {
lastTwoChars.isEmpty() -> {
// Nothing to do
}
length == 1 && lastTwoChars.peekLatest()?.isWhitespace() == true -> {
// Nothing to do
}
lastTwoChars.peekLatest() == '\n' -> {
// Nothing to do
}
else -> {
append('\n')
}
}
}
private fun CharSequence.secondToLast(): Char {
if (count() < 2) {
throw NoSuchElementException("List has less than two items.")
}
return this[lastIndex - 1]
}
private fun <T> MutableList<T>.pushMaxTwo(item: T) {
this.add(0, item)
if (count() > 2) {
this.removeLast()
}
}
private fun <T> List<T>.peekLatest(): T? {
return this.firstOrNull()
}
private fun <T> List<T>.peekSecondLatest(): T? {
if (count() < 2) {
return null
}
return this[1]
}
data class ComposableStyleWithStartEnd(
val style: @Composable () -> SpanStyle,
val start: Int,
val end: Int = -1
)

View File

@ -0,0 +1,101 @@
/*
* Feeder: Android RSS reader app
* https://gitlab.com/spacecowboy/Feeder
*
* Copyright (C) 2022 Jonas Kalderstam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package me.ash.reader.ui.component.reader
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
/**
* A continent version of [BasicText] component to be able to handle click event on the text.
*
* This is a shorthand of [BasicText] with [pointerInput] to be able to handle click
* event easily.
*
* @sample androidx.compose.foundation.samples.ClickableText
*
* For other gestures, e.g. long press, dragging, follow sample code.
*
* @sample androidx.compose.foundation.samples.LongClickableText
*
* @see BasicText
* @see androidx.compose.ui.input.pointer.pointerInput
* @see androidx.compose.foundation.gestures.detectTapGestures
*
* @param text The text to be displayed.
* @param modifier Modifier to apply to this layout node.
* @param style Style configuration for the text such as color, font, line height etc.
* @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the
* text will be positioned as if there was unlimited horizontal space. If [softWrap] is false,
* [overflow] and [TextAlign] may have unexpected effects.
* @param overflow How visual overflow should be handled.
* @param maxLines An optional maximum number of lines for the text to span, wrapping if
* necessary. If the text exceeds the given number of lines, it will be truncated according to
* [overflow] and [softWrap]. If it is not null, then it must be greater than zero.
* @param onTextLayout Callback that is executed when a new text layout is calculated.
* @param onClick Callback that is executed when users click the text. This callback is called
* with clicked character's offset.
*/
@Composable
fun ClickableTextWithInlineContent(
text: AnnotatedString,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
inlineContent: Map<String, InlineTextContent> = emptyMap(),
onTextLayout: (TextLayoutResult) -> Unit = {},
onClick: (Int) -> Unit,
) {
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(onClick) {
detectTapGestures { pos ->
layoutResult.value?.let { layoutResult ->
onClick(layoutResult.getOffsetForPosition(pos))
}
}
}
BasicText(
text = text,
modifier = modifier.then(pressIndicator),
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
onTextLayout = {
layoutResult.value = it
onTextLayout(it)
},
inlineContent = inlineContent
)
}

View File

@ -0,0 +1,73 @@
/*
* Feeder: Android RSS reader app
* https://gitlab.com/spacecowboy/Feeder
*
* Copyright (C) 2022 Jonas Kalderstam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package me.ash.reader.ui.component.reader
import android.content.res.Resources
import android.text.Annotation
import android.text.SpannedString
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.core.text.getSpans
@Composable
@ReadOnlyComposable
fun resources(): Resources {
LocalConfiguration.current
return LocalContext.current.resources
}
@Composable
fun annotatedStringResource(@StringRes id: Int): AnnotatedString {
val resources = resources()
val text = resources.getText(id) as SpannedString
return buildAnnotatedString {
this.append(text.toString())
for (annotation in text.getSpans<Annotation>()) {
when (annotation.key) {
"style" -> {
getSpanStyle(annotation.value)?.let { spanStyle ->
addStyle(
spanStyle,
text.getSpanStart(annotation),
text.getSpanEnd(annotation)
)
}
}
}
}
}
}
@Composable
private fun getSpanStyle(name: String?): SpanStyle? {
return when (name) {
"link" -> linkTextStyle().toSpanStyle()
else -> null
}
}

View File

@ -0,0 +1,751 @@
/*
* Feeder: Android RSS reader app
* https://gitlab.com/spacecowboy/Feeder
*
* Copyright (C) 2022 Jonas Kalderstam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package me.ash.reader.ui.component.reader
import androidx.annotation.DrawableRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.material.Text
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ExperimentalComposeApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.size.Precision
import coil.size.Size
import coil.size.pxOrElse
import me.ash.reader.R
import me.ash.reader.ui.component.AsyncImage
import org.jsoup.Jsoup
import org.jsoup.helper.StringUtil
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
import java.io.InputStream
import kotlin.math.abs
import kotlin.math.roundToInt
fun LazyListScope.htmlFormattedText(
inputStream: InputStream,
baseUrl: String,
@DrawableRes imagePlaceholder: Int,
onLinkClick: (String) -> Unit,
) {
Jsoup.parse(inputStream, null, baseUrl)
?.body()
?.let { body ->
formatBody(
element = body,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
}
}
private fun LazyListScope.formatBody(
element: Element,
@DrawableRes imagePlaceholder: Int,
onLinkClick: (String) -> Unit,
baseUrl: String,
) {
val composer = TextComposer { paragraphBuilder ->
item {
val paragraph = paragraphBuilder.toAnnotatedString()
// ClickableText prevents taps from deselecting selected text
// So use regular Text if possible
if (paragraph.getStringAnnotations("URL", 0, paragraph.length)
.isNotEmpty()
) {
ClickableText(
text = paragraph,
style = bodyStyle(),
modifier = Modifier
.padding(horizontal = PADDING_HORIZONTAL.dp)
.width(MAX_CONTENT_WIDTH.dp)
) { offset ->
paragraph.getStringAnnotations("URL", offset, offset)
.firstOrNull()
?.let {
onLinkClick(it.item)
}
}
} else {
Text(
text = paragraph,
style = bodyStyle(),
modifier = Modifier
.padding(horizontal = PADDING_HORIZONTAL.dp)
.width(MAX_CONTENT_WIDTH.dp)
)
}
}
}
composer.appendTextChildren(
element.childNodes(),
lazyListScope = this,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
composer.terminateCurrentText()
}
private fun LazyListScope.formatCodeBlock(
element: Element,
@DrawableRes imagePlaceholder: Int,
onLinkClick: (String) -> Unit,
baseUrl: String,
) {
val composer = TextComposer { paragraphBuilder ->
item {
val scrollState = rememberScrollState()
Spacer(modifier = Modifier.height(8.dp))
Surface(
color = codeBlockBackground(),
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.padding(horizontal = PADDING_HORIZONTAL.dp),
) {
Box(
modifier = Modifier
.padding(all = 8.dp)
.horizontalScroll(
state = scrollState
)
.width(MAX_CONTENT_WIDTH.dp)
) {
Text(
text = paragraphBuilder.toAnnotatedString(),
style = codeBlockStyle(),
softWrap = false
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
composer.appendTextChildren(
element.childNodes(), preFormatted = true,
lazyListScope = this,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
composer.terminateCurrentText()
}
@OptIn(ExperimentalComposeApi::class, ExperimentalCoilApi::class)
private fun TextComposer.appendTextChildren(
nodes: List<Node>,
preFormatted: Boolean = false,
lazyListScope: LazyListScope,
@DrawableRes imagePlaceholder: Int,
onLinkClick: (String) -> Unit,
baseUrl: String,
) {
var node = nodes.firstOrNull()
while (node != null) {
when (node) {
is TextNode -> {
if (preFormatted) {
append(node.wholeText)
} else {
if (endsWithWhitespace) {
node.text().trimStart().let { trimmed ->
if (trimmed.isNotEmpty()) {
append(trimmed)
}
}
} else {
node.text().let { text ->
if (text.isNotEmpty()) {
append(text)
}
}
}
}
}
is Element -> {
val element = node
when (element.tagName()) {
"p" -> {
// Readability4j inserts p-tags in divs for algorithmic purposes.
// They screw up formatting.
if (node.hasClass("readability-styled")) {
appendTextChildren(
element.childNodes(),
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
} else {
withParagraph {
appendTextChildren(
element.childNodes(),
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
}
}
}
"br" -> append('\n')
"h1" -> {
withParagraph {
withComposableStyle(
style = { h5Style().toSpanStyle() }
) {
append(element.text())
}
}
}
"h2" -> {
withParagraph {
withComposableStyle(
style = { h5Style().toSpanStyle() }
) {
append(element.text())
}
}
}
"h3" -> {
withParagraph {
withComposableStyle(
style = { h5Style().toSpanStyle() }
) {
append(element.text())
}
}
}
"h4" -> {
withParagraph {
withComposableStyle(
style = { h5Style().toSpanStyle() }
) {
append(element.text())
}
}
}
"h5" -> {
withParagraph {
withComposableStyle(
style = { h5Style().toSpanStyle() }
) {
append(element.text())
}
}
}
"h6" -> {
withParagraph {
withComposableStyle(
style = { h5Style().toSpanStyle() }
) {
append(element.text())
}
}
}
"strong", "b" -> {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
appendTextChildren(
element.childNodes(),
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
}
}
"i", "em", "cite", "dfn" -> {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
appendTextChildren(
element.childNodes(),
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
}
}
"tt" -> {
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) {
appendTextChildren(
element.childNodes(),
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
}
}
"u" -> {
withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
appendTextChildren(
element.childNodes(),
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
}
}
"sup" -> {
withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) {
appendTextChildren(
element.childNodes(),
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
}
}
"sub" -> {
withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) {
appendTextChildren(
element.childNodes(),
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
}
}
"font" -> {
val fontFamily: FontFamily? = element.attr("face")?.asFontFamily()
withStyle(SpanStyle(fontFamily = fontFamily)) {
appendTextChildren(
element.childNodes(),
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
}
}
"pre" -> {
appendTextChildren(
element.childNodes(),
preFormatted = true,
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
}
"code" -> {
if (element.parent()?.tagName() == "pre") {
terminateCurrentText()
lazyListScope.formatCodeBlock(
element = element,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
} else {
// inline code
withComposableStyle(
style = { codeInlineStyle() }
) {
appendTextChildren(
element.childNodes(),
preFormatted = preFormatted,
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
}
}
}
"blockquote" -> {
withParagraph {
withComposableStyle(
style = { blockQuoteStyle() }
) {
appendTextChildren(
element.childNodes(),
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
}
}
}
"a" -> {
withComposableStyle(
style = { linkTextStyle().toSpanStyle() }
) {
withAnnotation("URL", element.attr("abs:href") ?: "") {
appendTextChildren(
element.childNodes(),
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
}
}
}
"img" -> {
val imageCandidates = getImageSource(baseUrl, element)
if (imageCandidates.hasImage) {
val alt = element.attr("alt") ?: ""
appendImage(onLinkClick = onLinkClick) { onClick ->
lazyListScope.item {
// val scale = remember { mutableStateOf(1f) }
Column(
modifier = Modifier
// .padding(horizontal = PADDING_HORIZONTAL.dp)
.width(MAX_CONTENT_WIDTH.dp)
) {
DisableSelection {
BoxWithConstraints(
modifier = Modifier
.clip(RectangleShape)
.clickable(
enabled = onClick != null
) {
onClick?.invoke()
}
.fillMaxWidth()
// This makes scrolling a pain, find a way to solve that
// .pointerInput("imgzoom") {
// detectTransformGestures { centroid, pan, zoom, rotation ->
// val z = zoom * scale.value
// scale.value = when {
// z < 1f -> 1f
// z > 3f -> 3f
// else -> z
// }
// }
// }
) {
val imageSize = maxImageSize()
AsyncImage(
modifier = Modifier.fillMaxWidth(),
data = imageCandidates.getBestImageForMaxSize(
pixelDensity = pixelDensity(),
maxSize = imageSize,
),
contentDescription = alt,
size = imageSize,
precision = Precision.INEXACT,
contentScale = ContentScale.FillWidth,
)
}
}
if (alt.isNotBlank()) {
Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp / 2))
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = PADDING_HORIZONTAL.dp),
text = alt,
style = captionStyle(),
)
}
Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp))
}
}
}
}
}
"ul" -> {
element.children()
.filter { it.tagName() == "li" }
.forEach { listItem ->
withParagraph {
// no break space
append("")
appendTextChildren(
listItem.childNodes(),
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
}
}
}
"ol" -> {
element.children()
.filter { it.tagName() == "li" }
.forEachIndexed { i, listItem ->
withParagraph {
// no break space
append("${i + 1}. ")
appendTextChildren(
listItem.childNodes(),
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
}
}
}
"table" -> {
appendTable {
/*
In this order:
optionally a caption element (containing text children for instance),
followed by zero or more colgroup elements,
followed optionally by a thead element,
followed by either zero or more tbody elements
or one or more tr elements,
followed optionally by a tfoot element
*/
element.children()
.filter { it.tagName() == "caption" }
.forEach {
appendTextChildren(
it.childNodes(),
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
ensureDoubleNewline()
terminateCurrentText()
}
element.children()
.filter { it.tagName() == "thead" || it.tagName() == "tbody" || it.tagName() == "tfoot" }
.flatMap {
it.children()
.filter { it.tagName() == "tr" }
}
.forEach { row ->
appendTextChildren(
row.childNodes(),
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
terminateCurrentText()
}
append("\n\n")
}
}
"iframe" -> {
val video: Video? = getVideo(element.attr("abs:src"))
if (video != null) {
appendImage(onLinkClick = onLinkClick) {
lazyListScope.item {
Column(
modifier = Modifier
.padding(horizontal = PADDING_HORIZONTAL.dp)
.width(MAX_CONTENT_WIDTH.dp)
) {
DisableSelection {
BoxWithConstraints(
modifier = Modifier.fillMaxWidth()
) {
AsyncImage(
modifier = Modifier
.clickable {
onLinkClick(video.link)
}
.fillMaxWidth(),
data = video.imageUrl,
size = maxImageSize(),
contentDescription = "点击播放视频",
precision = Precision.INEXACT,
contentScale = ContentScale.FillWidth,
)
}
}
Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp / 2))
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = PADDING_HORIZONTAL.dp),
text = "点击播放视频",
style = captionStyle(),
)
Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp))
}
}
}
}
}
"video" -> {
// not implemented yet. remember to disable selection
}
else -> {
appendTextChildren(
nodes = element.childNodes(),
preFormatted = preFormatted,
lazyListScope = lazyListScope,
imagePlaceholder = imagePlaceholder,
onLinkClick = onLinkClick,
baseUrl = baseUrl,
)
}
}
}
}
node = node.nextSibling()
}
}
@OptIn(ExperimentalStdlibApi::class)
private fun String.asFontFamily(): FontFamily? = when (this.lowercase()) {
"monospace" -> FontFamily.Monospace
"serif" -> FontFamily.Serif
"sans-serif" -> FontFamily.SansSerif
else -> null
}
@Preview
@Composable
private fun testIt() {
val html = """
<p>In Gimp you go to <em>Image</em> in the top menu bar and select <em>Mode</em> followed by <em>Indexed</em>. Now you see a popup where you can select the number of colors for a generated optimum palette.</p> <p>You&rsquo;ll have to experiment a little because it will depend on your image.</p> <p>I used this approach to shrink the size of the cover image in <a href="https://cowboyprogrammer.org/2016/08/zopfli_all_the_things/">the_zopfli post</a> from a 37KB (JPG) to just 15KB (PNG, all PNG sizes listed include Zopfli compression btw).</p> <h2 id="straight-jpg-to-png-conversion-124kb">Straight JPG to PNG conversion: 124KB</h2> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things.png" alt="PNG version RGB colors" /></p> <p>First off, I exported the JPG file as a PNG file. This PNG file had a whopping 124KB! Clearly there was some bloat being stored.</p> <h2 id="256-colors-40kb">256 colors: 40KB</h2> <p>Reducing from RGB to only 256 colors has no visible effect to my eyes.</p> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_256.png" alt="256 colors" /></p> <h2 id="128-colors-34kb">128 colors: 34KB</h2> <p>Still no difference.</p> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_128.png" alt="128 colors" /></p> <h2 id="64-colors-25kb">64 colors: 25KB</h2> <p>You can start to see some artifacting in the shadow behind the text.</p> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_64.png" alt="64 colors" /></p> <h2 id="32-colors-15kb">32 colors: 15KB</h2> <p>In my opinion this is the sweet spot. The shadow artifacting is barely noticable but the size is significantly reduced.</p> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_32.png" alt="32 colors" /></p> <h2 id="16-colors-11kb">16 colors: 11KB</h2> <p>Clear artifacting in the text shadow and the yellow (fire?) in the background has developed an outline.</p> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_16.png" alt="16 colors" /></p> <h2 id="8-colors-7-3kb">8 colors: 7.3KB</h2> <p>The broom has shifted in color from a clear brown to almost grey. Text shadow is just a grey blob at this point. Even clearer outline developed on the yellow background.</p> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_8.png" alt="8 colors" /></p> <h2 id="4-colors-4-3kb">4 colors: 4.3KB</h2> <p>Interestingly enough, I think 4 colors looks better than 8 colors. The outline in the background has disappeared because there&rsquo;s not enough color spectrum to render it. The broom is now black and filled areas tend to get a white separator to the outlines.</p> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_4.png" alt="4 colors" /></p> <h2 id="2-colors-2-4kb">2 colors: 2.4KB</h2> <p>Well, at least the silhouette is well defined at this point I guess.</p> <p><img src="https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_2.png" alt="2 colors" /></p> <hr/> <p>Other posts in the <b>Migrating from Ghost to Hugo</b> series:</p> <ul class="series"> <li>2016-10-21 &mdash; Reduce the size of images even further by reducing number of colors with Gimp </li> <li>2016-08-26 &mdash; <a href="https://cowboyprogrammer.org/2016/08/zopfli_all_the_things/">Compress all the images!</a> </li> <li>2016-07-25 &mdash; <a href="https://cowboyprogrammer.org/2016/07/migrating_from_ghost_to_hugo/">Migrating from Ghost to Hugo</a> </li> </ul>
""".trimIndent()
html.byteInputStream().use { stream ->
LazyColumn {
htmlFormattedText(
inputStream = stream,
baseUrl = "https://cowboyprogrammer.org",
imagePlaceholder = R.drawable.ic_telegram,
onLinkClick = {}
)
}
}
}
@Composable
private fun pixelDensity() = with(LocalDensity.current) {
density
}
@Composable
private fun BoxWithConstraintsScope.maxImageSize() = with(LocalDensity.current) {
val maxWidthPx = maxWidth.toPx().roundToInt()
Size(
width = maxWidth.toPx().roundToInt().coerceAtLeast(1),
height = maxHeight
.toPx()
.roundToInt()
.coerceAtLeast(1)
.coerceAtMost(10 * maxWidthPx),
)
}
/**
* Gets the url to the image in the <img> tag - could be from srcset or from src
*/
internal fun getImageSource(baseUrl: String, element: Element) = ImageCandidates(
baseUrl = baseUrl,
srcSet = element.attr("srcset") ?: "",
absSrc = element.attr("abs:src") ?: "",
)
internal class ImageCandidates(
val baseUrl: String,
val srcSet: String,
val absSrc: String
) {
val hasImage: Boolean = srcSet.isNotBlank() || absSrc.isNotBlank()
/**
* Might throw if hasImage returns false
*/
fun getBestImageForMaxSize(maxSize: Size, pixelDensity: Float): String {
val setCandidate = srcSet.splitToSequence(",")
.map { it.trim() }
.map { it.split(SpaceRegex).take(2).map { x -> x.trim() } }
.fold(100f to "") { acc, candidate ->
val candidateSize = if (candidate.size == 1) {
// Assume it corresponds to 1x pixel density
1.0f / pixelDensity
} else {
val descriptor = candidate.last()
when {
descriptor.endsWith("w", ignoreCase = true) -> {
descriptor.substringBefore("w").toFloat() / maxSize.width.pxOrElse { 1 }
}
descriptor.endsWith("x", ignoreCase = true) -> {
descriptor.substringBefore("x").toFloat() / pixelDensity
}
else -> {
return@fold acc
}
}
}
if (abs(candidateSize - 1.0f) < abs(acc.first - 1.0f)) {
candidateSize to candidate.first()
} else {
acc
}
}
.second
if (setCandidate.isNotBlank()) {
return StringUtil.resolve(baseUrl, setCandidate)
}
return StringUtil.resolve(baseUrl, absSrc)
}
}
private val SpaceRegex = Regex("\\s+")

View File

@ -0,0 +1,49 @@
/*
* Feeder: Android RSS reader app
* https://gitlab.com/spacecowboy/Feeder
*
* Copyright (C) 2022 Jonas Kalderstam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package me.ash.reader.ui.component.reader
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.compose.foundation.lazy.LazyListScope
import me.ash.reader.R
fun LazyListScope.reader(
context: Context,
link: String,
content: String,
) {
Log.i("RLog", "Reader: ")
htmlFormattedText(
inputStream = content.byteInputStream(),
baseUrl = link,
imagePlaceholder = R.drawable.ic_launcher_foreground,
onLinkClick = {
context.startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(it)
)
)
}
)
}

View File

@ -0,0 +1,122 @@
/*
* Feeder: Android RSS reader app
* https://gitlab.com/spacecowboy/Feeder
*
* Copyright (C) 2022 Jonas Kalderstam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package me.ash.reader.ui.component.reader
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import me.ash.reader.ui.ext.alphaLN
const val PADDING_HORIZONTAL = 24.0
const val MAX_CONTENT_WIDTH = 840.0
@Composable
fun bodyForeground(): Color =
MaterialTheme.colorScheme.onSurfaceVariant
@Composable
fun bodyStyle(): TextStyle =
MaterialTheme.typography.bodyLarge.copy(
color = bodyForeground()
)
@Composable
fun h1Style(): TextStyle =
MaterialTheme.typography.displayMedium.copy(
color = bodyForeground()
)
@Composable
fun h2Style(): TextStyle =
MaterialTheme.typography.displaySmall.copy(
color = bodyForeground()
)
@Composable
fun h3Style(): TextStyle =
MaterialTheme.typography.headlineLarge.copy(
color = bodyForeground()
)
@Composable
fun h4Style(): TextStyle =
MaterialTheme.typography.headlineMedium.copy(
color = bodyForeground()
)
@Composable
fun h5Style(): TextStyle =
MaterialTheme.typography.headlineSmall.copy(
color = bodyForeground()
)
@Composable
fun h6Style(): TextStyle =
MaterialTheme.typography.titleLarge.copy(
color = bodyForeground()
)
@Composable
fun captionStyle(): TextStyle =
MaterialTheme.typography.bodySmall.copy(
color = bodyForeground().copy(alpha = 0.6f)
)
@Composable
fun linkTextStyle(): TextStyle =
TextStyle(
color = MaterialTheme.colorScheme.secondary,
textDecoration = TextDecoration.Underline
)
@Composable
fun codeBlockStyle(): TextStyle =
MaterialTheme.typography.titleSmall.merge(
SpanStyle(
color = bodyForeground(),
fontFamily = FontFamily.Monospace
)
)
@Composable
fun codeBlockBackground(): Color =
MaterialTheme.colorScheme.secondary.copy(alpha = (0.dp).alphaLN(weight = 3.2f))
@Composable
fun blockQuoteStyle(): SpanStyle =
MaterialTheme.typography.titleSmall.toSpanStyle().merge(
SpanStyle(
fontWeight = FontWeight.Light
)
)
@Composable
fun codeInlineStyle(): SpanStyle =
MaterialTheme.typography.titleSmall.toSpanStyle().copy(
color = bodyForeground(),
fontFamily = FontFamily.Monospace,
)

View File

@ -0,0 +1,182 @@
/*
* Feeder: Android RSS reader app
* https://gitlab.com/spacecowboy/Feeder
*
* Copyright (C) 2022 Jonas Kalderstam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package me.ash.reader.ui.component.reader
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.SpanStyle
class TextComposer(
val paragraphEmitter: (AnnotatedParagraphStringBuilder) -> Unit
) {
val spanStack: MutableList<Span> = mutableListOf()
// The identity of this will change - do not reference it in blocks
private var builder: AnnotatedParagraphStringBuilder = AnnotatedParagraphStringBuilder()
fun terminateCurrentText() {
if (builder.isEmpty()) {
// Nothing to emit, and nothing to reset
return
}
paragraphEmitter(builder)
builder = AnnotatedParagraphStringBuilder()
for (span in spanStack) {
when (span) {
is SpanWithStyle -> builder.pushStyle(span.spanStyle)
is SpanWithAnnotation -> builder.pushStringAnnotation(
tag = span.tag,
annotation = span.annotation
)
is SpanWithComposableStyle -> builder.pushComposableStyle(span.spanStyle)
}
}
}
val endsWithWhitespace: Boolean
get() = builder.endsWithWhitespace
fun ensureDoubleNewline() =
builder.ensureDoubleNewline()
fun append(text: String) =
builder.append(text)
fun append(char: Char) =
builder.append(char)
fun <R> appendTable(block: () -> R): R {
builder.ensureDoubleNewline()
terminateCurrentText()
return block()
}
fun <R> appendImage(
link: String? = null,
onLinkClick: (String) -> Unit,
block: (
onClick: (() -> Unit)?
) -> R
): R {
val url = link ?: findClosestLink()
builder.ensureDoubleNewline()
terminateCurrentText()
val onClick: (() -> Unit)? = if (url?.isNotBlank() == true) {
{
onLinkClick(url)
}
} else {
null
}
return block(onClick)
}
fun pop(index: Int) =
builder.pop(index)
fun pushStyle(style: SpanStyle): Int =
builder.pushStyle(style)
fun pushStringAnnotation(tag: String, annotation: String): Int =
builder.pushStringAnnotation(tag = tag, annotation = annotation)
fun pushComposableStyle(style: @Composable () -> SpanStyle): Int =
builder.pushComposableStyle(style)
fun popComposableStyle(index: Int) =
builder.popComposableStyle(index)
private fun findClosestLink(): String? {
for (span in spanStack.reversed()) {
if (span is SpanWithAnnotation && span.tag == "URL") {
return span.annotation
}
}
return null
}
}
inline fun <R : Any> TextComposer.withParagraph(
crossinline block: TextComposer.() -> R
): R {
ensureDoubleNewline()
return block(this)
}
inline fun <R : Any> TextComposer.withStyle(
style: SpanStyle,
crossinline block: TextComposer.() -> R
): R {
spanStack.add(SpanWithStyle(style))
val index = pushStyle(style)
return try {
block()
} finally {
pop(index)
spanStack.removeLast()
}
}
inline fun <R : Any> TextComposer.withComposableStyle(
noinline style: @Composable () -> SpanStyle,
crossinline block: TextComposer.() -> R
): R {
spanStack.add(SpanWithComposableStyle(style))
val index = pushComposableStyle(style)
return try {
block()
} finally {
popComposableStyle(index)
spanStack.removeLast()
}
}
inline fun <R : Any> TextComposer.withAnnotation(
tag: String,
annotation: String,
crossinline block: TextComposer.() -> R
): R {
spanStack.add(SpanWithAnnotation(tag = tag, annotation = annotation))
val index = pushStringAnnotation(tag = tag, annotation = annotation)
return try {
block()
} finally {
pop(index)
spanStack.removeLast()
}
}
sealed class Span
data class SpanWithStyle(
val spanStyle: SpanStyle
) : Span()
data class SpanWithAnnotation(
val tag: String,
val annotation: String
) : Span()
data class SpanWithComposableStyle(
val spanStyle: @Composable () -> SpanStyle
) : Span()

View File

@ -0,0 +1,54 @@
/*
* Feeder: Android RSS reader app
* https://gitlab.com/spacecowboy/Feeder
*
* Copyright (C) 2022 Jonas Kalderstam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package me.ash.reader.ui.component.reader
// Example strings
// www.youtube.com/embed/cjxnVO9RpaQ
// www.youtube.com/embed/cjxnVO9RpaQ?feature=oembed
// www.youtube.com/embed/cjxnVO9RpaQ/theoretical_crap
// www.youtube.com/embed/cjxnVO9RpaQ/crap?feature=oembed
internal val YoutubeIdPattern = "youtube.com/embed/([^?/]*)".toRegex()
fun getVideo(src: String?): Video? {
return src?.let {
YoutubeIdPattern.find(src)?.let { match ->
val videoId = match.groupValues[1]
Video(
src = src,
imageUrl = "http://img.youtube.com/vi/$videoId/hqdefault.jpg",
link = "https://www.youtube.com/watch?v=$videoId"
)
}
}
}
data class Video(
val src: String,
val imageUrl: String,
// Youtube needs a different link than embed links
val link: String
) {
val width: Int
get() = 480
val height: Int
get() = 360
}

View File

@ -27,6 +27,7 @@ package me.ash.reader.ui.ext
*/
import android.view.ViewConfiguration
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState
@ -88,7 +89,7 @@ fun Modifier.drawHorizontalScrollbar(
fun Modifier.drawVerticalScrollbar(
state: LazyListState,
reverseScrolling: Boolean = false
): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling)
): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling).animateContentSize()
private fun Modifier.drawScrollbar(
state: ScrollState,

View File

@ -7,7 +7,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.outlined.Article
import androidx.compose.material.icons.outlined.FiberManualRecord
import androidx.compose.material.icons.outlined.TextFormat
import androidx.compose.material.icons.outlined.Headphones
import androidx.compose.material.icons.rounded.Article
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Star
@ -108,9 +108,9 @@ fun ReadBar(
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}
CanBeDisabledIconButton(
modifier = Modifier.size(40.dp),
modifier = Modifier.size(36.dp),
disabled = true,
imageVector = Icons.Outlined.TextFormat,
imageVector = Icons.Outlined.Headphones,
contentDescription = "Add Tag",
tint = MaterialTheme.colorScheme.outline,
) {

View File

@ -1,20 +1,20 @@
package me.ash.reader.ui.page.home.read
import android.util.Log
import android.content.Intent
import androidx.compose.animation.*
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Headphones
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
@ -23,7 +23,7 @@ import androidx.navigation.NavHostController
import me.ash.reader.R
import me.ash.reader.data.entity.ArticleWithFeed
import me.ash.reader.ui.component.FeedbackIconButton
import me.ash.reader.ui.component.WebView
import me.ash.reader.ui.component.reader.reader
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.drawVerticalScrollbar
@ -34,7 +34,8 @@ fun ReadPage(
readViewModel: ReadViewModel = hiltViewModel(),
) {
val viewState = readViewModel.viewState.collectAsStateValue()
var isScrollDown by remember { mutableStateOf(false) }
val isScrollDown = viewState.listState.isScrollDown()
// val isScrollDown by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
navController.currentBackStackEntryFlow.collect {
@ -44,24 +45,7 @@ fun ReadPage(
}
}
if (viewState.scrollState.isScrollInProgress) {
LaunchedEffect(Unit) {
Log.i("RLog", "scroll: start")
}
val preScrollOffset by remember { mutableStateOf(viewState.scrollState.value) }
val currentOffset = viewState.scrollState.value
isScrollDown = currentOffset > preScrollOffset
DisposableEffect(Unit) {
onDispose {
Log.i("RLog", "scroll: end")
}
}
}
LaunchedEffect(viewState.articleWithFeed?.article?.id) {
isScrollDown = false
viewState.articleWithFeed?.let {
if (it.article.isUnread) {
readViewModel.dispatch(ReadViewAction.MarkUnread(false))
@ -82,7 +66,8 @@ fun ReadPage(
) {
TopBar(
isShow = viewState.articleWithFeed == null || !isScrollDown,
isShowActions = viewState.articleWithFeed != null,
title = viewState.articleWithFeed?.article?.title,
link = viewState.articleWithFeed?.article?.link,
onClose = {
navController.popBackStack()
},
@ -91,8 +76,8 @@ fun ReadPage(
Content(
content = viewState.content ?: "",
articleWithFeed = viewState.articleWithFeed,
viewState = viewState,
scrollState = viewState.scrollState,
isLoading = viewState.isLoading,
listState = viewState.listState,
)
Box(
modifier = Modifier
@ -121,12 +106,39 @@ fun ReadPage(
)
}
@Composable
fun LazyListState.isScrollDown(): Boolean {
var isScrollDown by remember { mutableStateOf(false) }
var preItemIndex by remember { mutableStateOf(0) }
var preScrollStartOffset by remember { mutableStateOf(0) }
LaunchedEffect(this) {
snapshotFlow { isScrollInProgress }.collect {
if (isScrollInProgress) {
isScrollDown = when {
firstVisibleItemIndex > preItemIndex -> true
firstVisibleItemScrollOffset < preItemIndex -> false
else -> firstVisibleItemScrollOffset > preScrollStartOffset
}
} else {
preItemIndex = firstVisibleItemIndex
preScrollStartOffset = firstVisibleItemScrollOffset
}
}
}
return isScrollDown
}
@Composable
private fun TopBar(
isShow: Boolean,
isShowActions: Boolean = false,
title: String? = "",
link: String? = "",
onClose: () -> Unit = {},
) {
val context = LocalContext.current
AnimatedVisibility(
visible = isShow,
enter = fadeIn() + expandVertically(),
@ -148,23 +160,19 @@ private fun TopBar(
}
},
actions = {
if (isShowActions) {
FeedbackIconButton(
modifier = Modifier
.size(22.dp)
.alpha(0.5f),
imageVector = Icons.Outlined.Headphones,
contentDescription = stringResource(R.string.mark_all_as_read),
tint = MaterialTheme.colorScheme.onSurface,
) {
}
FeedbackIconButton(
modifier = Modifier.alpha(0.5f),
imageVector = Icons.Outlined.MoreVert,
contentDescription = stringResource(R.string.search),
tint = MaterialTheme.colorScheme.onSurface,
) {
}
FeedbackIconButton(
modifier = Modifier.size(20.dp),
imageVector = Icons.Outlined.Share,
contentDescription = stringResource(R.string.search),
tint = MaterialTheme.colorScheme.onSurface,
) {
context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
putExtra(
Intent.EXTRA_TEXT,
title?.takeIf { it.isNotBlank() }?.let { it + "\n" } + link
)
type = "text/plain"
}, "Share"))
}
}
)
@ -175,37 +183,37 @@ private fun TopBar(
private fun Content(
content: String,
articleWithFeed: ArticleWithFeed?,
viewState: ReadViewState,
scrollState: ScrollState = rememberScrollState(),
listState: LazyListState,
isLoading: Boolean,
) {
Column(
modifier = Modifier
.statusBarsPadding()
.navigationBarsPadding()
.drawVerticalScrollbar(scrollState)
.verticalScroll(scrollState),
) {
if (articleWithFeed == null) {
Spacer(modifier = Modifier.height(64.dp))
// LottieAnimation(
// modifier = Modifier
// .alpha(0.7f)
// .padding(80.dp),
// url = "https://assets8.lottiefiles.com/packages/lf20_jm7mv1ib.json",
// )
} else {
Column {
if (articleWithFeed == null) return
val context = LocalContext.current
SelectionContainer {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding()
.drawVerticalScrollbar(listState),
state = listState,
) {
item {
Spacer(modifier = Modifier.height(64.dp))
Spacer(modifier = Modifier.height(2.dp))
Spacer(modifier = Modifier.height(22.dp))
Column(
modifier = Modifier
.padding(horizontal = 12.dp)
) {
Header(articleWithFeed)
DisableSelection {
Header(articleWithFeed)
}
}
}
item {
Spacer(modifier = Modifier.height(22.dp))
AnimatedVisibility(
visible = viewState.isLoading,
visible = isLoading,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
@ -224,12 +232,15 @@ private fun Content(
}
}
}
if (!viewState.isLoading) {
WebView(
content = content
)
Spacer(modifier = Modifier.height(50.dp))
}
}
if (!isLoading) {
reader(
context = context,
link = articleWithFeed.article.link,
content = content
)
}
item {
Spacer(modifier = Modifier.height(64.dp))
Spacer(modifier = Modifier.height(64.dp))
}

View File

@ -1,7 +1,7 @@
package me.ash.reader.ui.page.home.read
import android.util.Log
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.LazyListState
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@ -146,7 +146,8 @@ data class ReadViewState(
val articleWithFeed: ArticleWithFeed? = null,
val content: String? = null,
val isLoading: Boolean = true,
val scrollState: ScrollState = ScrollState(0),
// val scrollState: ScrollState = ScrollState(0),
val listState: LazyListState = LazyListState(),
)
sealed class ReadViewAction {

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19,3L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM19,19L5,19v-4.58l0.99,0.99 4,-4 4,4 4,-3.99L19,12.43L19,19zM19,9.59l-1.01,-1.01 -4,4.01 -4,-4 -4,4 -0.99,-1L5,5h14v4.59z"
android:fillColor="#000000"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M8,2c-1.1,0 -2,0.9 -2,2v3.17c0,0.53 0.21,1.04 0.59,1.42L10,12l-3.42,3.42c-0.37,0.38 -0.58,0.89 -0.58,1.42L6,20c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2v-3.16c0,-0.53 -0.21,-1.04 -0.58,-1.41L14,12l3.41,-3.4c0.38,-0.38 0.59,-0.89 0.59,-1.42L18,4c0,-1.1 -0.9,-2 -2,-2L8,2zM16,16.5L16,19c0,0.55 -0.45,1 -1,1L9,20c-0.55,0 -1,-0.45 -1,-1v-2.5l4,-4 4,4zM12,11.5l-4,-4L8,5c0,-0.55 0.45,-1 1,-1h6c0.55,0 1,0.45 1,1v2.5l-4,4z"
android:fillColor="#000000"/>
</vector>